From 6941f7e9d541261be4c0b4bad24f3c4610eaafd3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 16:21:47 +1100 Subject: [PATCH 01/66] Updated code to use libSession for message encoding --- .../Crypto/Crypto+LibSession.swift | 144 +++++++++--- .../Crypto/Crypto+SessionMessagingKit.swift | 45 ---- .../LibSession+GroupKeys.swift | 17 ++ .../LibSession+SessionMessagingKit.swift | 2 + .../Open Groups/Crypto/Crypto+OpenGroup.swift | 39 ---- .../Errors/MessageSenderError.swift | 2 + .../Sending & Receiving/MessageSender.swift | 131 ++--------- .../Utilities/MessageWrapper.swift | 60 ----- SessionUtilitiesKit/Crypto/CryptoError.swift | 1 + .../Utilities/TypeConversion+Utilities.swift | 212 ++++++++++++++++++ 10 files changed, 364 insertions(+), 289 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index ff4bb93852..4adddba986 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -94,40 +94,116 @@ public extension Crypto.Generator { } } - static func ciphertextForGroupMessage( - groupSessionId: SessionId, - message: [UInt8] - ) -> Crypto.Generator { + static func ciphertextForDestination( + plaintext: I, + destination: Message.Destination, + sentTimestampMs: UInt64 + ) throws -> Crypto.Generator where R.Element == UInt8 { return Crypto.Generator( - id: "ciphertextForGroupMessage", - args: [groupSessionId, message] + id: "ciphertextForDestination", + args: [] ) { dependencies in - return try dependencies.mutate(cache: .libSession) { cache in - guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) - } - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - groups_keys_encrypt_message( - conf, - message, - message.count, - &maybeCiphertext, - &ciphertextLen - ) - - guard - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext - .map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - return ciphertext - } ?? { throw MessageSenderError.encryptionFailed }() + let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } + + let cPlaintext: [UInt8] = Array(plaintext) + var error: [CChar] = [CChar](repeating: 0, count: 256) + var result: session_protocol_encoded_for_destination + + switch destination { + case .contact(let pubkey): + var cPubkey: bytes33 = bytes33() + cPubkey.set(\.data, to: Data(hex: pubkey)) + result = session_protocol_encode_for_1o1( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + nil, + 0, + &error, + error.count + ) + + case .syncMessage: + var cPubkey: bytes33 = bytes33() + cPubkey.set(\.data, to: Data(hex: dependencies[cache: .general].sessionId.hexString)) + result = session_protocol_encode_for_1o1( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + nil, + 0, + &error, + error.count + ) + + case .closedGroup(let pubkey): + let currentGroupEncPrivateKey: [UInt8] = try dependencies.mutate(cache: .libSession) { cache in + try cache.latestGroupKey(groupSessionId: SessionId(.group, hex: pubkey)) + } + + var cPubkey: bytes33 = bytes33() + var cCurrentGroupEncPrivateKey: bytes32 = bytes32() + cPubkey.set(\.data, to: Data(hex: pubkey)) + cCurrentGroupEncPrivateKey.set(\.data, to: currentGroupEncPrivateKey) + result = session_protocol_encode_for_group( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cPubkey, + &cCurrentGroupEncPrivateKey, + nil, + 0, + &error, + error.count + ) + + case .openGroupInbox(_, let serverPubkey, let recipientPubkey): + var cServerPubkey: bytes32 = bytes32() + var cRecipientPubkey: bytes33 = bytes33() + cServerPubkey.set(\.data, to: Data(hex: serverPubkey)) + cRecipientPubkey.set(\.data, to: Data(hex: recipientPubkey)) + result = session_protocol_encode_for_community_inbox( + cPlaintext, + cPlaintext.count, + cEd25519SecretKey, + cEd25519SecretKey.count, + sentTimestampMs, + &cRecipientPubkey, + &cServerPubkey, + nil, + 0, + &error, + error.count + ) + + case .openGroup: + result = session_protocol_encode_for_community( + cPlaintext, + cPlaintext.count, + nil, + 0, + &error, + error.count + ) + } + defer { session_protocol_encode_for_destination_free(&result) } + + guard result.success else { + Log.error(.messageSender, "Failed to encrypt due to error: \(String(cString: error))") + throw MessageSenderError.encryptionFailed + } + + return R(UnsafeBufferPointer(start: result.ciphertext.data, count: result.ciphertext.size)) } } @@ -203,3 +279,7 @@ public extension Crypto.Verification { } } } + +extension bytes32: CAccessible & CMutable {} +extension bytes33: CAccessible & CMutable {} +extension bytes64: CAccessible & CMutable {} diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index ff92a72277..ddecdb8e83 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -11,51 +11,6 @@ import SessionUtilitiesKit // MARK: - Encryption public extension Crypto.Generator { - static func ciphertextWithSessionProtocol( - plaintext: Data, - destination: Message.Destination - ) -> Crypto.Generator { - return Crypto.Generator( - id: "ciphertextWithSessionProtocol", - args: [plaintext, destination] - ) { dependencies in - let destinationX25519PublicKey: Data = try { - switch destination { - case .contact(let publicKey): return Data(SessionId(.standard, hex: publicKey).publicKey) - case .syncMessage: return Data(dependencies[cache: .general].sessionId.publicKey) - case .closedGroup: throw MessageSenderError.deprecatedLegacyGroup - default: throw MessageSenderError.signingFailed - } - }() - - var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - cDestinationPubKey.count == 32, - session_encrypt_for_recipient_deterministic( - &cPlaintext, - cPlaintext.count, - &cEd25519SecretKey, - &cDestinationPubKey, - &maybeCiphertext, - &ciphertextLen - ), - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) - - return ciphertext - } - } - static func ciphertextWithMultiEncrypt( messages: [Data], toRecipients recipients: [SessionId], diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift index dfd4c4c62e..4320c770e5 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift @@ -204,6 +204,21 @@ internal extension LibSession { // MARK: - State Accses public extension LibSession.Cache { + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { + guard let config: LibSession.Config = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + let result: span_u8 = groups_keys_group_enc_key(conf); + + guard result.size > 0 else { throw CryptoError.invalidKey } + + return Array(UnsafeBufferPointer(start: result.data, count: result.size)) + } + func isAdmin(groupSessionId: SessionId) -> Bool { guard case .groupKeys(let conf, _, _) = config(for: .groupKeys, sessionId: groupSessionId) else { return false @@ -212,3 +227,5 @@ public extension LibSession.Cache { return groups_keys_is_admin(conf) } } + +extension span_u8: @retroactive CAccessible {} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 93df1e6c7a..1cadf87630 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1100,6 +1100,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func hasCredentials(groupSessionId: SessionId) -> Bool func secretKey(groupSessionId: SessionId) -> [UInt8]? + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] func isAdmin(groupSessionId: SessionId) -> Bool func loadAdminKey( groupIdentitySeed: Data, @@ -1380,6 +1381,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func hasCredentials(groupSessionId: SessionId) -> Bool { return false } func secretKey(groupSessionId: SessionId) -> [UInt8]? { return nil } + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { throw CryptoError.invalidKey } func isAdmin(groupSessionId: SessionId) -> Bool { return false } func markAsInvited(groupSessionIds: [String]) throws {} func markAsKicked(groupSessionIds: [String]) throws {} diff --git a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift index 2ff8f76fc6..e7aff6b058 100644 --- a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift @@ -9,45 +9,6 @@ import SessionUtilitiesKit // MARK: - Messages public extension Crypto.Generator { - static func ciphertextWithSessionBlindingProtocol( - plaintext: Data, - recipientBlindedId: String, - serverPublicKey: String - ) -> Crypto.Generator { - return Crypto.Generator( - id: "ciphertextWithSessionBlindingProtocol", - args: [plaintext, serverPublicKey] - ) { dependencies in - var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cRecipientBlindedId: [UInt8] = Array(Data(hex: recipientBlindedId)) - var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - cServerPublicKey.count == 32, - session_encrypt_for_blinded_recipient( - &cPlaintext, - cPlaintext.count, - &cEd25519SecretKey, - &cServerPublicKey, - &cRecipientBlindedId, - &maybeCiphertext, - &ciphertextLen - ), - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) - - return ciphertext - } - } - static func plaintextWithSessionBlindingProtocol( ciphertext: Data, senderId: String, diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index 4a4062cfd4..0aac85b8ed 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -16,6 +16,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case attachmentsNotUploaded case attachmentsInvalid case blindingFailed + case invalidDestination // Closed groups case noThread @@ -48,6 +49,7 @@ public enum MessageSenderError: Error, CustomStringConvertible, Equatable { case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded (MessageSenderError.attachmentsNotUploaded)." case .attachmentsInvalid: return "Attachments Invalid (MessageSenderError.attachmentsInvalid)." case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." + case .invalidDestination: return "Invalid destination (MessageSenderError.invalidDestination)." // Closed groups case .noThread: return "Couldn't find a thread associated with the given group public key (MessageSenderError.noThread)." diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8752d80003..14565db6bd 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -388,120 +388,25 @@ public final class MessageSender { message.isValid(isSending: true), let sentTimestampMs: UInt64 = message.sentTimestampMs else { throw MessageSenderError.invalidMessage } + + /// Messages sent to `revokedRetrievableGroupMessages` should be sent directly instead of via the `MessageSender` + guard namespace != .revokedRetrievableGroupMessages else { + throw MessageSenderError.invalidDestination + } - let plaintext: Data = try { - switch (namespace, destination) { - case (.revokedRetrievableGroupMessages, _): - return try BencodeEncoder(using: dependencies).encode(message) - - case (_, .openGroup), (_, .openGroupInbox): - guard - let proto: SNProtoContent = try message.toProto()? - .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) - else { throw MessageSenderError.protoConversionFailed } - - return try Result(proto.serializedData().paddedMessageBody()) - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - - default: - guard - let proto: SNProtoContent = try message.toProto()? - .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) - else { throw MessageSenderError.protoConversionFailed } - - return try Result(proto.serializedData()) - .map { serialisedData -> Data in - switch destination { - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: - return serialisedData - - default: return serialisedData.paddedMessageBody() - } - } - .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } - .successOrThrow() - } - }() + /// Add attachments if needed and convert to serialised proto data + guard + let plaintext: Data = try? message.toProto()? + .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment })? + .serializedData() + else { throw MessageSenderError.protoConversionFailed } - switch (destination, namespace) { - /// Updated group messages should be wrapped _before_ encrypting - case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - let messageData: Data = try Result( - MessageWrapper.wrap( - type: .closedGroupMessage, - timestampMs: sentTimestampMs, - content: plaintext, - wrapInWebSocketMessage: false - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextForGroupMessage( - groupSessionId: SessionId(.group, hex: groupId), - message: Array(messageData) - ) - ) - return ciphertext - - /// `revokedRetrievableGroupMessages` should be sent in plaintext (their content has custom encryption) - case (.closedGroup(let groupId), .revokedRetrievableGroupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - return plaintext - - // Standard one-to-one messages and legacy groups (which used a `05` prefix) - case (.contact, .default), (.syncMessage, _), (.closedGroup, _): - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .ciphertextWithSessionProtocol( - plaintext: plaintext, - destination: destination - ) - ) - - return try Result( - try MessageWrapper.wrap( - type: try { - switch destination { - case .contact, .syncMessage: return .sessionMessage - case .closedGroup: return .closedGroupMessage - default: throw MessageSenderError.invalidMessage - } - }(), - timestampMs: sentTimestampMs, - senderPublicKey: { - switch destination { - case .closedGroup: return try authMethod.swarmPublicKey // Needed for Android - default: return "" // Empty for all other cases - } - }(), - content: ciphertext - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } - .successOrThrow() - - /// Community messages should be sent in plaintext - case (.openGroup, _): return plaintext - - /// Blinded community messages have their own special encryption - case (.openGroupInbox(_, let serverPublicKey, let recipientBlindedPublicKey), _): - return try dependencies[singleton: .crypto].generateResult( - .ciphertextWithSessionBlindingProtocol( - plaintext: plaintext, - recipientBlindedId: recipientBlindedPublicKey, - serverPublicKey: serverPublicKey - ) - ) - .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } - .successOrThrow() - - /// Config messages should be sent directly rather than via this method - case (.closedGroup(let groupId), _) where (try? SessionId.Prefix(from: groupId)) == .group: - throw MessageSenderError.invalidConfigMessageHandling - - /// Config messages should be sent directly rather than via this method - case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling - } + return try dependencies[singleton: .crypto].tryGenerate( + .ciphertextForDestination( + plaintext: Array(plaintext), + destination: destination, + sentTimestampMs: sentTimestampMs + ) + ) } } diff --git a/SessionMessagingKit/Utilities/MessageWrapper.swift b/SessionMessagingKit/Utilities/MessageWrapper.swift index 3ff9796b7b..c8c4f378d2 100644 --- a/SessionMessagingKit/Utilities/MessageWrapper.swift +++ b/SessionMessagingKit/Utilities/MessageWrapper.swift @@ -7,75 +7,15 @@ import SessionUtilitiesKit public enum MessageWrapper { public enum Error : LocalizedError { - case failedToWrapData - case failedToWrapMessageInEnvelope - case failedToWrapEnvelopeInWebSocketMessage case failedToUnwrapData public var errorDescription: String? { switch self { - case .failedToWrapData: return "Failed to wrap data." - case .failedToWrapMessageInEnvelope: return "Failed to wrap message in envelope." - case .failedToWrapEnvelopeInWebSocketMessage: return "Failed to wrap envelope in web socket message." case .failedToUnwrapData: return "Failed to unwrap data." } } } - /// Wraps the given parameters in an `SNProtoEnvelope` and then a `WebSocketProtoWebSocketMessage` to match the desktop application. - public static func wrap( - type: SNProtoEnvelope.SNProtoEnvelopeType, - timestampMs: UInt64, - senderPublicKey: String = "", // FIXME: Remove once legacy groups are deprecated - content: Data, - wrapInWebSocketMessage: Bool = true - ) throws -> Data { - do { - let envelope: SNProtoEnvelope = try createEnvelope( - type: type, - timestamp: timestampMs, - senderPublicKey: senderPublicKey, - content: content - ) - - // If we don't want to wrap the message within the `WebSocketProtoWebSocketMessage` type - // the just serialise and return here - guard wrapInWebSocketMessage else { return try envelope.serializedData() } - - // Otherwise add the additional wrapper - let webSocketMessage = try createWebSocketMessage(around: envelope) - return try webSocketMessage.serializedData() - } catch let error { - throw error as? Error ?? Error.failedToWrapData - } - } - - private static func createEnvelope(type: SNProtoEnvelope.SNProtoEnvelopeType, timestamp: UInt64, senderPublicKey: String, content: Data) throws -> SNProtoEnvelope { - do { - let builder = SNProtoEnvelope.builder(type: type, timestamp: timestamp) - builder.setSource(senderPublicKey) - builder.setSourceDevice(1) - builder.setContent(content) - return try builder.build() - } catch let error { - Log.error(.messageSender, "Failed to wrap message in envelope: \(error).") - throw Error.failedToWrapMessageInEnvelope - } - } - - private static func createWebSocketMessage(around envelope: SNProtoEnvelope) throws -> WebSocketProtoWebSocketMessage { - do { - let requestBuilder = WebSocketProtoWebSocketRequestMessage.builder(verb: "", path: "", requestID: 0) - requestBuilder.setBody(try envelope.serializedData()) - let messageBuilder = WebSocketProtoWebSocketMessage.builder(type: .request) - messageBuilder.setRequest(try requestBuilder.build()) - return try messageBuilder.build() - } catch let error { - Log.error(.messageSender, "Failed to wrap envelope in web socket message: \(error).") - throw Error.failedToWrapEnvelopeInWebSocketMessage - } - } - /// - Note: `data` shouldn't be base 64 encoded. public static func unwrap( data: Data, diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 9ed1bfe208..d4cba38c81 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -14,4 +14,5 @@ public enum CryptoError: Error { case missingUserSecretKey case invalidAuthentication case invalidBase64EncodedData + case invalidKey } diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index 300b7f7748..f0599684bf 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import SessionUtil // MARK: - String @@ -83,9 +84,21 @@ public protocol CAccessible { func get(_ keyPath: KeyPath) -> Data func get(_ keyPath: KeyPath) -> [UInt8] func getHex(_ keyPath: KeyPath) -> String + func get(_ keyPath: KeyPath) -> Data + func get(_ keyPath: KeyPath) -> [UInt8] + func getHex(_ keyPath: KeyPath) -> String + func get(_ keyPath: KeyPath) -> Data + func get(_ keyPath: KeyPath) -> [UInt8] + func getHex(_ keyPath: KeyPath) -> String + func get(_ keyPath: KeyPath) -> Data + func get(_ keyPath: KeyPath) -> [UInt8] + func getHex(_ keyPath: KeyPath) -> String func get(_ keyPath: KeyPath) -> Data func get(_ keyPath: KeyPath) -> [UInt8] func getHex(_ keyPath: KeyPath) -> String + func get(_ keyPath: KeyPath) -> Data + func get(_ keyPath: KeyPath) -> [UInt8] + func getHex(_ keyPath: KeyPath) -> String func get(_ keyPath: KeyPath) -> Data func get(_ keyPath: KeyPath) -> [UInt8] func getHex(_ keyPath: KeyPath) -> String @@ -93,9 +106,21 @@ public protocol CAccessible { func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? @@ -135,9 +160,21 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } @@ -151,6 +188,33 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -160,6 +224,15 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -189,7 +262,11 @@ public protocol CMutable { // Data variants mutating func set(_ keyPath: WritableKeyPath, to value: T?) + mutating func set(_ keyPath: WritableKeyPath, to value: T?) + mutating func set(_ keyPath: WritableKeyPath, to value: T?) + mutating func set(_ keyPath: WritableKeyPath, to value: T?) mutating func set(_ keyPath: WritableKeyPath, to value: T?) + mutating func set(_ keyPath: WritableKeyPath, to value: T?) mutating func set(_ keyPath: WritableKeyPath, to value: T?) } @@ -206,10 +283,26 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } @@ -273,9 +366,21 @@ public extension UnsafeMutablePointer { func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } + func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } + func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } + func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } + func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } @@ -289,6 +394,33 @@ public extension UnsafeMutablePointer { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) } @@ -298,6 +430,15 @@ public extension UnsafeMutablePointer { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) } @@ -328,10 +469,26 @@ public extension UnsafeMutablePointer { setData(keyPath, value.map { Data($0) }, length: 32) } + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }, length: 32) + } + + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }, length: 33) + } + + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }, length: 33) + } + func set(_ keyPath: WritableKeyPath, to value: T?) { setData(keyPath, value.map { Data($0) }, length: 64) } + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }, length: 64) + } + func set(_ keyPath: WritableKeyPath, to value: T?) { setData(keyPath, value.map { Data($0) }, length: 100) } @@ -371,9 +528,21 @@ public extension UnsafePointer { func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 32) } func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 32)) } func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 32).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 32) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 32)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 32).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 33) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 33)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 33).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 33) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 33)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 33).toHexString() } func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 64) } func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 64)) } func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 64).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 64) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 64)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 64).toHexString() } func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 100) } func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 100)) } func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 100).toHexString() } @@ -387,6 +556,33 @@ public extension UnsafePointer { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty) } @@ -396,6 +592,15 @@ public extension UnsafePointer { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty) } @@ -531,6 +736,13 @@ public typealias CUChar32 = ( UInt8, UInt8 ) +public typealias CUChar33 = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8 +) + public typealias CUChar64 = ( UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, From 8d9cb6a5f7b971df542220e342bfa0702c334c6a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 12:33:58 +1100 Subject: [PATCH 02/66] Renamed message Origin/Destination to use "community" and "group" --- .../Crypto/Crypto+LibSession.swift | 212 +++++++++--------- .../Jobs/GroupLeavingJob.swift | 2 +- SessionMessagingKit/Jobs/MessageSendJob.swift | 4 +- ...ProcessPendingGroupMemberRemovalsJob.swift | 2 +- .../Messages/Message+Destination.swift | 26 +-- .../Messages/Message+Origin.swift | 2 +- SessionMessagingKit/Messages/Message.swift | 12 +- .../Open Groups/OpenGroupManager.swift | 2 +- .../MessageSender+Groups.swift | 16 +- .../Sending & Receiving/MessageReceiver.swift | 2 +- .../MessageSender+Convenience.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 26 +-- .../MessageViewModel+DeletionActions.swift | 6 +- .../MessageReceiverGroupsSpec.swift | 2 +- .../MessageSenderGroupsSpec.swift | 6 +- .../ThreadSettingsViewModelSpec.swift | 2 +- 16 files changed, 165 insertions(+), 159 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 4adddba986..d6283e1e11 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -4,103 +4,16 @@ import Foundation import SessionUtil import SessionUtilitiesKit +// MARK: - Messages + public extension Crypto.Generator { - static func tokenSubaccount( - config: LibSession.Config?, - groupSessionId: SessionId, - memberId: String - ) -> Crypto.Generator<[UInt8]> { - return Crypto.Generator( - id: "tokenSubaccount", - args: [config, groupSessionId, memberId] - ) { - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var tokenData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountBytes) - - guard groups_keys_swarm_subaccount_token( - conf, - &cMemberId, - &tokenData - ) else { throw LibSessionError.failedToMakeSubAccountInGroup } - - return tokenData - } - } - - static func memberAuthData( - config: LibSession.Config?, - groupSessionId: SessionId, - memberId: String - ) -> Crypto.Generator { - return Crypto.Generator( - id: "memberAuthData", - args: [config, groupSessionId, memberId] - ) { - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var authData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeAuthDataBytes) - - guard groups_keys_swarm_make_subaccount( - conf, - &cMemberId, - &authData - ) else { throw LibSessionError.failedToMakeSubAccountInGroup } - - return .groupMember(groupSessionId: groupSessionId, authData: Data(authData)) - } - } - - static func signatureSubaccount( - config: LibSession.Config?, - verificationBytes: [UInt8], - memberAuthData: Data - ) -> Crypto.Generator { - return Crypto.Generator( - id: "signatureSubaccount", - args: [config, verificationBytes, memberAuthData] - ) { - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var verificationBytes: [UInt8] = verificationBytes - var memberAuthData: [UInt8] = Array(memberAuthData) - var subaccount: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountBytes) - var subaccountSig: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountSigBytes) - var signature: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountSignatureBytes) - - guard groups_keys_swarm_subaccount_sign_binary( - conf, - &verificationBytes, - verificationBytes.count, - &memberAuthData, - &subaccount, - &subaccountSig, - &signature - ) else { throw MessageSenderError.signingFailed } - - return Authentication.Signature.subaccount( - subaccount: subaccount, - subaccountSig: subaccountSig, - signature: signature - ) - } - } - - static func ciphertextForDestination( + static func encodedMessage( plaintext: I, destination: Message.Destination, sentTimestampMs: UInt64 ) throws -> Crypto.Generator where R.Element == UInt8 { return Crypto.Generator( - id: "ciphertextForDestination", + id: "encodedMessage", args: [] ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey @@ -144,7 +57,7 @@ public extension Crypto.Generator { error.count ) - case .closedGroup(let pubkey): + case .group(let pubkey): let currentGroupEncPrivateKey: [UInt8] = try dependencies.mutate(cache: .libSession) { cache in try cache.latestGroupKey(groupSessionId: SessionId(.group, hex: pubkey)) } @@ -167,7 +80,17 @@ public extension Crypto.Generator { error.count ) - case .openGroupInbox(_, let serverPubkey, let recipientPubkey): + case .community: + result = session_protocol_encode_for_community( + cPlaintext, + cPlaintext.count, + nil, + 0, + &error, + error.count + ) + + case .communityInbox(_, let serverPubkey, let recipientPubkey): var cServerPubkey: bytes32 = bytes32() var cRecipientPubkey: bytes33 = bytes33() cServerPubkey.set(\.data, to: Data(hex: serverPubkey)) @@ -185,16 +108,6 @@ public extension Crypto.Generator { &error, error.count ) - - case .openGroup: - result = session_protocol_encode_for_community( - cPlaintext, - cPlaintext.count, - nil, - 0, - &error, - error.count - ) } defer { session_protocol_encode_for_destination_free(&result) } @@ -253,6 +166,99 @@ public extension Crypto.Generator { } } +// MARK: - Groups + +public extension Crypto.Generator { + static func tokenSubaccount( + config: LibSession.Config?, + groupSessionId: SessionId, + memberId: String + ) -> Crypto.Generator<[UInt8]> { + return Crypto.Generator( + id: "tokenSubaccount", + args: [config, groupSessionId, memberId] + ) { + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() + var tokenData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountBytes) + + guard groups_keys_swarm_subaccount_token( + conf, + &cMemberId, + &tokenData + ) else { throw LibSessionError.failedToMakeSubAccountInGroup } + + return tokenData + } + } + + static func memberAuthData( + config: LibSession.Config?, + groupSessionId: SessionId, + memberId: String + ) -> Crypto.Generator { + return Crypto.Generator( + id: "memberAuthData", + args: [config, groupSessionId, memberId] + ) { + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + var cMemberId: [CChar] = try memberId.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() + var authData: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeAuthDataBytes) + + guard groups_keys_swarm_make_subaccount( + conf, + &cMemberId, + &authData + ) else { throw LibSessionError.failedToMakeSubAccountInGroup } + + return .groupMember(groupSessionId: groupSessionId, authData: Data(authData)) + } + } + + static func signatureSubaccount( + config: LibSession.Config?, + verificationBytes: [UInt8], + memberAuthData: Data + ) -> Crypto.Generator { + return Crypto.Generator( + id: "signatureSubaccount", + args: [config, verificationBytes, memberAuthData] + ) { + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + var verificationBytes: [UInt8] = verificationBytes + var memberAuthData: [UInt8] = Array(memberAuthData) + var subaccount: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountBytes) + var subaccountSig: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountSigBytes) + var signature: [UInt8] = [UInt8](repeating: 0, count: LibSession.sizeSubaccountSignatureBytes) + + guard groups_keys_swarm_subaccount_sign_binary( + conf, + &verificationBytes, + verificationBytes.count, + &memberAuthData, + &subaccount, + &subaccountSig, + &signature + ) else { throw MessageSenderError.signingFailed } + + return Authentication.Signature.subaccount( + subaccount: subaccount, + subaccountSig: subaccountSig, + signature: signature + ) + } + } +} + public extension Crypto.Verification { static func memberAuthData( groupSessionId: SessionId, diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index 208072da67..6caec66c7c 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -34,7 +34,7 @@ public enum GroupLeavingJob: JobExecutor { let interactionId: Int64 = job.interactionId else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) + let destination: Message.Destination = .group(publicKey: threadId) dependencies[singleton: .storage] .writePublisher(updates: { db -> RequestType in diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index bbb2b1c7fe..e83b8aca8d 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -165,9 +165,9 @@ public enum MessageSendJob: JobExecutor { var previousDeferralsMessage: String = "" switch details.destination { - case .closedGroup(let groupPublicKey) where groupPublicKey.starts(with: SessionId.Prefix.group.rawValue): + case .group(let publicKey) where publicKey.starts(with: SessionId.Prefix.group.rawValue): let deferalDuration: TimeInterval = 1 - let groupSessionId: SessionId = SessionId(.group, hex: groupPublicKey) + let groupSessionId: SessionId = SessionId(.group, hex: publicKey) let numGroupKeys: Int = (try? LibSession.numKeys(groupSessionId: groupSessionId, using: dependencies)) .defaulting(to: 0) let deferCount: Int = dependencies[singleton: .jobRunner].deferCount(for: job.id, of: job.variant) diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index 0253242bc7..607fb81514 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -164,7 +164,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { ), using: dependencies ), - to: .closedGroup(groupPublicKey: groupSessionId.hexString), + to: .group(publicKey: groupSessionId.hexString), namespace: .groupMessages, interactionId: nil, attachments: nil, diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index a61a43ec3d..dcaa9936aa 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -15,10 +15,10 @@ public extension Message { /// A one-to-one destination where `groupPublicKey` is a `standard` `SessionId` for legacy groups /// and a `group` `SessionId` for updated groups - case closedGroup(groupPublicKey: String) + case group(publicKey: String) /// A message directed to an open group - case openGroup( + case community( roomToken: String, server: String, whisperTo: String? = nil, @@ -26,27 +26,27 @@ public extension Message { ) /// A message directed to an open group inbox - case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) + case communityInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) public var threadVariant: SessionThread.Variant { switch self { - case .contact, .syncMessage, .openGroupInbox: return .contact - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + case .contact, .syncMessage, .communityInbox: return .contact + case .group(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: return .group - case .closedGroup: return .legacyGroup - case .openGroup: return .community + case .group: return .legacyGroup + case .community: return .community } } public var defaultNamespace: Network.SnodeAPI.Namespace? { switch self { case .contact, .syncMessage: return .`default` - case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + case .group(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: return .groupMessages - case .closedGroup: return .legacyClosedGroup - case .openGroup, .openGroupInbox: return nil + case .group: return .legacyClosedGroup + case .community, .communityInbox: return nil } } @@ -64,7 +64,7 @@ public extension Message { throw SOGSError.blindedLookupMissingCommunityInfo } - return .openGroupInbox( + return .communityInbox( server: lookup.openGroupServer, openGroupPublicKey: lookup.openGroupPublicKey, blindedPublicKey: threadId @@ -73,7 +73,7 @@ public extension Message { return .contact(publicKey: threadId) - case .legacyGroup, .group: return .closedGroup(groupPublicKey: threadId) + case .legacyGroup, .group: return .group(publicKey: threadId) case .community: guard @@ -81,7 +81,7 @@ public extension Message { .fetchOne(db, id: threadId) else { throw StorageError.objectNotFound } - return .openGroup(roomToken: info.roomToken, server: info.server) + return .community(roomToken: info.roomToken, server: info.server) } } } diff --git a/SessionMessagingKit/Messages/Message+Origin.swift b/SessionMessagingKit/Messages/Message+Origin.swift index 1c0d5f330a..437ad2fba2 100644 --- a/SessionMessagingKit/Messages/Message+Origin.swift +++ b/SessionMessagingKit/Messages/Message+Origin.swift @@ -22,7 +22,7 @@ public extension Message { whisperMods: Bool, whisperTo: String? ) - case openGroupInbox( + case communityInbox( timestamp: TimeInterval, messageServerId: Int64, serverPublicKey: String, diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 2583d84d7c..be0582d7d8 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -421,11 +421,11 @@ public extension Message { return (maybeSyncTarget ?? publicKey) - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroup(let roomToken, let server, _, _): + case .group(let publicKey): return publicKey + case .community(let roomToken, let server, _, _): return OpenGroup.idFor(roomToken: roomToken, server: server) - case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey + case .communityInbox(_, _, let blindedPublicKey): return blindedPublicKey } } @@ -582,9 +582,9 @@ public extension Message { // Disappear after sent messages with exceptions case (_, is UnsendRequest): return message.ttl - case (.closedGroup, is GroupUpdateInviteMessage), (.closedGroup, is GroupUpdateInviteResponseMessage), - (.closedGroup, is GroupUpdatePromoteMessage), (.closedGroup, is GroupUpdateMemberLeftMessage), - (.closedGroup, is GroupUpdateDeleteMemberContentMessage): + case (.group, is GroupUpdateInviteMessage), (.group, is GroupUpdateInviteResponseMessage), + (.group, is GroupUpdatePromoteMessage), (.group, is GroupUpdateMemberLeftMessage), + (.group, is GroupUpdateDeleteMemberContentMessage): return message.ttl default: diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e0b7269ffd..76eaed78d8 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -726,7 +726,7 @@ public final class OpenGroupManager { do { let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: messageData, - origin: .openGroupInbox( + origin: .communityInbox( timestamp: message.posted, messageServerId: message.id, serverPublicKey: openGroup.publicKey, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index a0129669ac..479f4af263 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -100,7 +100,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: createdInfo.group.id, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: createdInfo.group.id), + destination: .group(publicKey: createdInfo.group.id), message: GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: sortedOtherMembers.map { id, _ in id }, @@ -327,7 +327,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .name, updatedName: name, @@ -389,7 +389,7 @@ extension MessageSender { using: dependencies ) - case .groupUpdateTo(let url, let key, let fileName): + case .groupUpdateTo(let url, let key, _): try ClosedGroup .filter(id: groupSessionId) .updateAllAndConfig( @@ -431,7 +431,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .avatar, sentTimestampMs: UInt64(changeTimestampMs), @@ -513,7 +513,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateInfoChangeMessage( changeType: .disappearingMessages, updatedExpiration: UInt32(updatedConfig.isEnabled ? @@ -725,7 +725,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: sortedMembers.map { id, _ in id }, @@ -1060,7 +1060,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: sessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), + destination: .group(publicKey: sessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: sortedMemberIds, @@ -1203,7 +1203,7 @@ extension MessageSender { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupSessionId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupSessionId.hexString), + destination: .group(publicKey: groupSessionId.hexString), message: GroupUpdateMemberChangeMessage( changeType: .promoted, memberSessionIds: sortedMembersReceivingPromotions.map { id, _ in id }, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 91ce5e83db..04c5b95daf 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -67,7 +67,7 @@ public enum MessageReceiver { return openGroupId } - case (_, .openGroupInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): + case (_, .communityInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( .plaintextWithSessionBlindingProtocol( ciphertext: data, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f10e1dca4c..a036aa229f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -208,7 +208,7 @@ extension MessageSender { case (false, .syncMessage): try interaction.with(state: .sent).update(db) - case (true, .syncMessage), (_, .contact), (_, .closedGroup), (_, .openGroup), (_, .openGroupInbox): + case (true, .syncMessage), (_, .contact), (_, .group), (_, .community), (_, .communityInbox): // The timestamp to use for scheduling message deletion. This is generated // when the message is successfully sent to ensure the deletion timer starts // from the correct time. diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 14565db6bd..eb83cf6f86 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -65,7 +65,7 @@ public final class MessageSender { let preparedRequest: Network.PreparedRequest switch destination { - case .contact, .syncMessage, .closedGroup: + case .contact, .syncMessage, .group: preparedRequest = try preparedSendToSnodeDestination( message: updatedMessage, to: destination, @@ -78,8 +78,8 @@ public final class MessageSender { using: dependencies ) - case .openGroup: - preparedRequest = try preparedSendToOpenGroupDestination( + case .community: + preparedRequest = try preparedSendToCommunityDestination( message: updatedMessage, to: destination, interactionId: interactionId, @@ -90,8 +90,8 @@ public final class MessageSender { using: dependencies ) - case .openGroupInbox: - preparedRequest = try preparedSendToOpenGroupInboxDestination( + case .communityInbox: + preparedRequest = try preparedSendToCommunityInboxDestination( message: message, to: destination, interactionId: interactionId, @@ -183,8 +183,8 @@ public final class MessageSender { switch destination { case .contact(let publicKey): return publicKey case .syncMessage: return userSessionId.hexString - case .closedGroup(let groupPublicKey): return groupPublicKey - case .openGroup, .openGroupInbox: preconditionFailure() + case .group(let publicKey): return publicKey + case .community, .communityInbox: preconditionFailure() } }() let snodeMessage = SnodeMessage( @@ -220,7 +220,7 @@ public final class MessageSender { } } - private static func preparedSendToOpenGroupDestination( + private static func preparedSendToCommunityDestination( message: Message, to destination: Message.Destination, interactionId: Int64?, @@ -236,7 +236,7 @@ public final class MessageSender { guard let message: VisibleMessage = message as? VisibleMessage, case .community(let server, let publicKey, let hasCapabilities, let supportsBlinding, _) = authMethod.info, - case .openGroup(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, + case .community(let roomToken, let destinationServer, let whisperTo, let whisperMods) = destination, server == destinationServer, let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) @@ -310,7 +310,7 @@ public final class MessageSender { } } - private static func preparedSendToOpenGroupInboxDestination( + private static func preparedSendToCommunityInboxDestination( message: Message, to destination: Message.Destination, interactionId: Int64?, @@ -320,10 +320,10 @@ public final class MessageSender { onEvent: ((Event) -> Void)?, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - // The `openGroupInbox` destination does not support attachments + /// The `communityInbox` destination does not support attachments guard (attachments ?? []).isEmpty, - case .openGroupInbox(_, _, let recipientBlindedPublicKey) = destination + case .communityInbox(_, _, let recipientBlindedPublicKey) = destination else { throw MessageSenderError.invalidMessage } let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -402,7 +402,7 @@ public final class MessageSender { else { throw MessageSenderError.protoConversionFailed } return try dependencies[singleton: .crypto].tryGenerate( - .ciphertextForDestination( + .encodedMessage( plaintext: Array(plaintext), destination: destination, sentTimestampMs: sentTimestampMs diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index 67fd9318a2..ca4a74491c 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -473,7 +473,7 @@ public extension MessageViewModel.DeletionBehaviours { expiresInSeconds: model.expiresInSeconds, expiresStartedAtMs: model.expiresStartedAtMs ), - to: .closedGroup(groupPublicKey: model.threadId), + to: .group(publicKey: model.threadId), namespace: .legacyClosedGroup, interactionId: nil, attachments: nil, @@ -541,7 +541,7 @@ public extension MessageViewModel.DeletionBehaviours { authMethod: nil, using: dependencies ), - to: .closedGroup(groupPublicKey: threadData.threadId), + to: .group(publicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, attachments: nil, @@ -600,7 +600,7 @@ public extension MessageViewModel.DeletionBehaviours { ), using: dependencies ), - to: .closedGroup(groupPublicKey: threadData.threadId), + to: .group(publicKey: threadData.threadId), namespace: .groupMessages, interactionId: nil, attachments: nil, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 9c0ec8387c..b1107b2605 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -1987,7 +1987,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, interactionId: nil, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try! GroupUpdateMemberChangeMessage( changeType: .removed, memberSessionIds: [ diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index 80139dd1c5..1c1c69f85d 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -1178,7 +1178,7 @@ class MessageSenderGroupsSpec: QuickSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ @@ -1371,7 +1371,7 @@ class MessageSenderGroupsSpec: QuickSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ @@ -1416,7 +1416,7 @@ class MessageSenderGroupsSpec: QuickSpec { behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, threadId: groupId.hexString, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupId.hexString), + destination: .group(publicKey: groupId.hexString), message: try GroupUpdateMemberChangeMessage( changeType: .added, memberSessionIds: [ diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 77514dedf8..13d3c9b646 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -751,7 +751,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { threadId: groupPubkey, interactionId: nil, details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: groupPubkey), + destination: .group(publicKey: groupPubkey), message: try GroupUpdateInfoChangeMessage( changeType: .name, updatedName: "TestNewGroupName", From f00a2e3b3d1f8088d7ebaf11aa0ff24ad8b170ac Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 12:51:22 +1100 Subject: [PATCH 03/66] Added in initial logic for message decoding via libSession --- Session.xcodeproj/project.pbxproj | 4 - .../Crypto/Crypto+LibSession.swift | 107 +++++++ .../Messages/Message+Origin.swift | 20 +- .../Open Groups/OpenGroupManager.swift | 5 +- .../Errors/MessageReceiverError.swift | 4 +- .../Sending & Receiving/MessageReceiver.swift | 276 +++++++----------- .../General/Collection+Utilities.swift | 77 ----- .../Utilities/TypeConversion+Utilities.swift | 80 +++++ 8 files changed, 301 insertions(+), 272 deletions(-) delete mode 100644 SessionUtilitiesKit/General/Collection+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ef261630d7..5a0adc942f 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -278,7 +278,6 @@ B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */; }; B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */; }; B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */; }; - B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */; }; B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; }; B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; }; B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; @@ -1670,7 +1669,6 @@ B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessage.swift; sourceTree = ""; }; B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = ""; }; B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = ""; }; - B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Utilities.swift"; sourceTree = ""; }; B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = ""; }; B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = ""; }; B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; @@ -3070,7 +3068,6 @@ FD47E0AA2AA68EEA00A55E41 /* Authentication.swift */, FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, - B8F5F58225EC94A6003BF8D4 /* Collection+Utilities.swift */, FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, FD2272EB2C352155004D8A6C /* Feature.swift */, @@ -6617,7 +6614,6 @@ FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */, FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */, - B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */, FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */, diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index d6283e1e11..5efe5b5c51 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -2,6 +2,7 @@ import Foundation import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Messages @@ -120,6 +121,110 @@ public extension Crypto.Generator { } } + static func decodedMessage( + encodedMessage: I, + origin: Message.Origin + ) throws -> Crypto.Generator<(proto: SNProtoContent, sender: String, sentTimestampMs: UInt64)> { + return Crypto.Generator( + id: "decodedMessage", + args: [] + ) { dependencies in + let cEncodedMessage: [UInt8] = Array(encodedMessage) + let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + var error: [CChar] = [CChar](repeating: 0, count: 256) + + /// Communities have a separate decoding function to handle them first + if case .community(_, let sender, let posted, _, _, _, _) = origin { + var result: session_protocol_decoded_community_message = session_protocol_decode_for_community( + cEncodedMessage, + cEncodedMessage.count, + currentTimestampMs, + nil, + 0, + &error, + error.count + ) + defer { session_protocol_decode_for_community_free(&result) } + + guard result.success else { + Log.error(.messageSender, "Failed to decrypt community message due to error: \(String(cString: error))") + throw MessageReceiverError.decryptionFailed + } + + let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext_unpadded_size)) + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + + return (proto, sender, sentTimestampMs) + } + + guard case .swarm(let publicKey, let namespace, let serverHash, let serverTimestampMs, let serverExpirationTimestamp) = origin else { + throw MessageReceiverError.invalidMessage + } + + /// Function to provide pointers to the keys based on the namespace the message was received from + func withKeys( + for namespace: Network.SnodeAPI.Namespace, + using dependencies: Dependencies, + _ closure: (UnsafePointer?, Int) throws -> R + ) throws -> R { + let privateKeys: [[UInt8]] + + switch namespace { + case .default: + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + + guard !ed25519SecretKey.isEmpty else { throw MessageReceiverError.noUserED25519KeyPair } + + privateKeys = [ed25519SecretKey] + + case .groupMessages: + throw MessageReceiverError.invalidMessage + + default: throw MessageReceiverError.invalidMessage + } + + return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in + try closure(cPrivateKeys, cPrivateKeysLen) + } + } + + return try withKeys(for: namespace, using: dependencies) { cPrivateKeys, cPrivateKeysLen in + let cEncodedMessage: [UInt8] = Array(encodedMessage) + var cKeys: session_protocol_decode_envelope_keys = session_protocol_decode_envelope_keys() + cKeys.set(\.ed25519_privkeys, to: cPrivateKeys) + cKeys.set(\.ed25519_privkeys_len, to: cPrivateKeysLen) + + var result: session_protocol_decoded_envelope = session_protocol_decode_envelope( + &cKeys, + cEncodedMessage, + cEncodedMessage.count, + currentTimestampMs, + nil, + 0, + &error, + error.count + ) + defer { session_protocol_decode_envelope_free(&result) } + + guard result.success else { + Log.error(.messageSender, "Failed to decrypt message due to error: \(String(cString: error))") + throw MessageReceiverError.decryptionFailed + } + + let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext.size)) + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sender: SessionId = SessionId(.standard, publicKey: result.get(\.sender_x25519_pubkey)) + + return (proto, sender.hexString, result.envelope.timestamp_ms) + } + } + } + static func plaintextForGroupMessage( groupSessionId: SessionId, ciphertext: [UInt8] @@ -289,3 +394,5 @@ public extension Crypto.Verification { extension bytes32: CAccessible & CMutable {} extension bytes33: CAccessible & CMutable {} extension bytes64: CAccessible & CMutable {} +extension session_protocol_decode_envelope_keys: CAccessible & CMutable {} +extension session_protocol_decoded_envelope: CAccessible & CMutable {} diff --git a/SessionMessagingKit/Messages/Message+Origin.swift b/SessionMessagingKit/Messages/Message+Origin.swift index 437ad2fba2..d11d280d21 100644 --- a/SessionMessagingKit/Messages/Message+Origin.swift +++ b/SessionMessagingKit/Messages/Message+Origin.swift @@ -16,7 +16,7 @@ public extension Message { case community( openGroupId: String, sender: String, - timestamp: TimeInterval?, + posted: TimeInterval, messageServerId: Int64, whisper: Bool, whisperMods: Bool, @@ -37,25 +37,11 @@ public extension Message { } } - public var isCommunity: Bool { + public var isRevokedRetrievableNamespace: Bool { switch self { - case .community: return true + case .swarm(_, let namespace, _, _, _): return (namespace == .revokedRetrievableGroupMessages) default: return false } } - - public var serverHash: String? { - switch self { - case .swarm(_, _, let serverHash, _, _): return serverHash - default: return nil - } - } - - public var serverExpirationTimestamp: TimeInterval? { - switch self { - case .swarm(_, _, _, _, let expirationTimestamp): return expirationTimestamp - default: return nil - } - } } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 76eaed78d8..739174d9aa 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -563,7 +563,8 @@ public final class OpenGroupManager { if let base64EncodedString: String = message.base64EncodedData, let data = Data(base64Encoded: base64EncodedString), - let sender: String = message.sender + let sender: String = message.sender, + let posted: TimeInterval = message.posted { do { let processedMessage: ProcessedMessage = try MessageReceiver.parse( @@ -571,7 +572,7 @@ public final class OpenGroupManager { origin: .community( openGroupId: openGroup.id, sender: sender, - timestamp: message.posted, + posted: posted, messageServerId: message.id, whisper: message.whisper, whisperMods: message.whisperMods, diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 15d5488927..d38d087df0 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -27,13 +27,14 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case missingRequiredAdminPrivileges case deprecatedMessage case failedToProcess + case communitiesDoNotSupportControlMessages public var isRetryable: Bool { switch self { case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, - .missingRequiredAdminPrivileges, .failedToProcess: + .missingRequiredAdminPrivileges, .failedToProcess, .communitiesDoNotSupportControlMessages: return false default: return true @@ -114,6 +115,7 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." case .deprecatedMessage: return "This message type has been deprecated." case .failedToProcess: return "Failed to process." + case .communitiesDoNotSupportControlMessages: return "Communities do not support control messages." } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 04c5b95daf..b9a11f90f2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -22,201 +22,135 @@ public enum MessageReceiver { origin: Message.Origin, using dependencies: Dependencies ) throws -> ProcessedMessage { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let uniqueIdentifier: String - var plaintext: Data - var customProto: SNProtoContent? = nil - var customMessage: Message? = nil - let sender: String - let sentTimestampMs: UInt64? - let serverHash: String? - let openGroupServerMessageId: UInt64? - let openGroupWhisper: Bool - let openGroupWhisperMods: Bool - let openGroupWhisperTo: String? + /// Config messages are custom-handled internally within `libSession` so just return the data directly + guard !origin.isConfigNamespace else { + guard case .swarm(let publicKey, let namespace, let serverHash, let timestampMs, _) = origin else { + throw MessageReceiverError.invalidConfigMessageHandling + } + + return .config( + publicKey: publicKey, + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: timestampMs, + data: data, + uniqueIdentifier: serverHash + ) + } + + /// The group "revoked retrievable" namespace uses custom encryption so we need to custom handle it + guard !origin.isRevokedRetrievableNamespace else { + guard case .swarm(let publicKey, _, let serverHash, _, let serverExpirationTimestamp) = origin else { + throw MessageReceiverError.invalidMessage + } + + let proto: SNProtoContent = try SNProtoContent.builder().build() + let message: LibSessionMessage = LibSessionMessage(ciphertext: data) + message.sender = publicKey /// The "group" sends these messages + message.serverHash = serverHash + + return .standard( + threadId: publicKey, + threadVariant: .group, + proto: proto, + messageInfo: try MessageReceiveJob.Details.MessageInfo( + message: message, + variant: try Message.Variant(from: message) ?? { + throw MessageReceiverError.invalidMessage + }(), + threadVariant: .group, + serverExpirationTimestamp: serverExpirationTimestamp, + proto: proto + ), + uniqueIdentifier: serverHash + ) + } + + /// For all other cases we can just decode the message + let (proto, sender, sentTimestampMs): (SNProtoContent, String, UInt64) = try dependencies[singleton: .crypto].tryGenerate( + .decodedMessage( + encodedMessage: data, + origin: origin + ) + ) + + let threadId: String let threadVariant: SessionThread.Variant - let threadIdGenerator: (Message) throws -> String + let serverExpirationTimestamp: TimeInterval? + let uniqueIdentifier: String + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let message: Message = try Message.createMessageFrom(proto, sender: sender, using: dependencies) + message.sender = sender + message.sentTimestampMs = sentTimestampMs + message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) + message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - switch (origin.isConfigNamespace, origin) { - // Config messages are custom-handled via 'libSession' so just return the data directly - case (true, .swarm(let publicKey, let namespace, let serverHash, let serverTimestampMs, _)): - return .config( - publicKey: publicKey, - namespace: namespace, - serverHash: serverHash, - serverTimestampMs: serverTimestampMs, - data: data, - uniqueIdentifier: serverHash - ) + /// Perform validation and assign additional message values based on the origin + switch origin { + case .community(let openGroupId, _, _, let messageServerId, let whisper, let whisperMods, let whisperTo): + /// Don't allow control messages in community conversations + guard message is VisibleMessage else { + throw MessageReceiverError.communitiesDoNotSupportControlMessages + } - case (_, .community(let openGroupId, let messageSender, let timestamp, let messageServerId, let messageWhisper, let messageWhisperMods, let messageWhisperTo)): - uniqueIdentifier = "\(messageServerId)" - plaintext = data.removePadding() // Remove the padding - sender = messageSender - sentTimestampMs = timestamp.map { UInt64(floor($0 * 1000)) } // Convert to ms for database consistency - serverHash = nil - openGroupServerMessageId = UInt64(messageServerId) - openGroupWhisper = messageWhisper - openGroupWhisperMods = messageWhisperMods - openGroupWhisperTo = messageWhisperTo + threadId = openGroupId threadVariant = .community - threadIdGenerator = { message in - // Guard against control messages in open groups - guard message is VisibleMessage else { throw MessageReceiverError.invalidMessage } - - return openGroupId - } + serverExpirationTimestamp = nil + uniqueIdentifier = "\(messageServerId)" + message.openGroupServerMessageId = UInt64(messageServerId) + message.openGroupWhisper = whisper + message.openGroupWhisperMods = whisperMods + message.openGroupWhisperTo = whisperTo - case (_, .communityInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): - (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionBlindingProtocol( - ciphertext: data, - senderId: senderId, - recipientId: recipientId, - serverPublicKey: serverPublicKey - ) - ) + case .communityInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId): + // TODO: Add this case + fatalError() - uniqueIdentifier = "\(messageServerId)" - plaintext = plaintext.removePadding() // Remove the padding - sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency - serverHash = nil - openGroupServerMessageId = UInt64(messageServerId) - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil - threadVariant = .contact - threadIdGenerator = { _ in sender } + case .swarm(let publicKey, let namespace, let serverHash, _, let expirationTimestamp): + /// Don't process 1-to-1 or group messages if the sender is blocked + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: sender) + }) || + message.processWithBlockedSender + else { throw MessageReceiverError.senderBlocked } - case (_, .swarm(let publicKey, let namespace, let swarmServerHash, _, _)): - uniqueIdentifier = swarmServerHash - serverHash = swarmServerHash + /// Ignore self sends if needed + guard message.isSelfSendValid || sender != userSessionId.hexString else { + throw MessageReceiverError.selfSend + } switch namespace { case .default: - guard - let envelope: SNProtoEnvelope = try? MessageWrapper.unwrap(data: data), - let ciphertext: Data = envelope.content - else { - Log.warn(.messageReceiver, "Failed to unwrap data for message from 'default' namespace.") - throw MessageReceiverError.invalidMessage - } - - (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionProtocol(ciphertext: ciphertext) + threadId = Message.threadId( + forMessage: message, + destination: .contact(publicKey: sender), + using: dependencies ) - plaintext = plaintext.removePadding() // Remove the padding - sentTimestampMs = envelope.timestamp - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil threadVariant = .contact - threadIdGenerator = { message in - Message.threadId(forMessage: message, destination: .contact(publicKey: sender), using: dependencies) - } case .groupMessages: - let plaintextEnvelope: Data - (plaintextEnvelope, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextForGroupMessage( - groupSessionId: SessionId(.group, hex: publicKey), - ciphertext: Array(data) - ) - ) - - guard - let envelope: SNProtoEnvelope = try? MessageWrapper.unwrap( - data: plaintextEnvelope, - includesWebSocketMessage: false - ), - let envelopeContent: Data = envelope.content - else { - Log.warn(.messageReceiver, "Failed to unwrap data for message from 'default' namespace.") - throw MessageReceiverError.invalidMessage - } - plaintext = envelopeContent // Padding already removed for updated groups - sentTimestampMs = envelope.timestamp - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil + threadId = publicKey threadVariant = .group - threadIdGenerator = { _ in publicKey } - case .revokedRetrievableGroupMessages: - plaintext = Data() // Requires custom decryption - - let contentProto: SNProtoContent.SNProtoContentBuilder = SNProtoContent.builder() - contentProto.setSigTimestamp(0) - customProto = try contentProto.build() - customMessage = LibSessionMessage(ciphertext: data) - sender = publicKey // The "group" sends these messages - sentTimestampMs = 0 - openGroupServerMessageId = nil - openGroupWhisper = false - openGroupWhisperMods = false - openGroupWhisperTo = nil - threadVariant = .group - threadIdGenerator = { _ in publicKey } - - case .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups: - throw MessageReceiverError.invalidConfigMessageHandling - - case .configGroupInfo, .configGroupMembers, .configGroupKeys: - throw MessageReceiverError.invalidConfigMessageHandling - - case .legacyClosedGroup: throw MessageReceiverError.deprecatedMessage - case .configLocal, .all, .unknown: + default: Log.warn(.messageReceiver, "Couldn't process message due to invalid namespace.") - throw MessageReceiverError.unknownMessage(nil) + throw MessageReceiverError.unknownMessage(proto) } + + serverExpirationTimestamp = expirationTimestamp + uniqueIdentifier = serverHash + message.serverHash = serverHash + message.attachDisappearingMessagesConfiguration(from: proto) } - let proto: SNProtoContent = try (customProto ?? Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .successOrThrow()) - let message: Message = try (customMessage ?? Message.createMessageFrom(proto, sender: sender, using: dependencies)) - message.sender = sender - message.serverHash = serverHash - message.sentTimestampMs = sentTimestampMs - message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) - message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - message.openGroupServerMessageId = openGroupServerMessageId - message.openGroupWhisper = openGroupWhisper - message.openGroupWhisperMods = openGroupWhisperMods - message.openGroupWhisperTo = openGroupWhisperTo - - // Ignore disappearing message settings in communities (in case of modified clients) - if threadVariant != .community { - message.attachDisappearingMessagesConfiguration(from: proto) - } - - // Don't process the envelope any further if the sender is blocked - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.isContactBlocked(contactId: sender) - }) || - message.processWithBlockedSender - else { throw MessageReceiverError.senderBlocked } - - // Ignore self sends if needed - guard message.isSelfSendValid || sender != userSessionId.hexString else { - throw MessageReceiverError.selfSend - } - - // Guard against control messages in open groups - guard !origin.isCommunity || message is VisibleMessage else { - throw MessageReceiverError.invalidMessage - } - - // Validate + /// Ensure the message is valid guard message.isValid(isSending: false) else { throw MessageReceiverError.invalidMessage } return .standard( - threadId: try threadIdGenerator(message), + threadId: threadId, threadVariant: threadVariant, proto: proto, messageInfo: try MessageReceiveJob.Details.MessageInfo( @@ -225,7 +159,7 @@ public enum MessageReceiver { throw MessageReceiverError.invalidMessage }(), threadVariant: threadVariant, - serverExpirationTimestamp: origin.serverExpirationTimestamp, + serverExpirationTimestamp: serverExpirationTimestamp, proto: proto ), uniqueIdentifier: uniqueIdentifier diff --git a/SessionUtilitiesKit/General/Collection+Utilities.swift b/SessionUtilitiesKit/General/Collection+Utilities.swift deleted file mode 100644 index 2c6e60cac5..0000000000 --- a/SessionUtilitiesKit/General/Collection+Utilities.swift +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public extension Collection where Element == String { - func withUnsafeCStrArray( - _ body: (UnsafeBufferPointer?>) throws -> R - ) throws -> R { - let pointerArray = UnsafeMutablePointer?>.allocate(capacity: self.count) - var allocatedCStrings: [UnsafeMutablePointer?] = [] - allocatedCStrings.reserveCapacity(self.count) - defer { - for ptr in allocatedCStrings { - free(ptr) /// Need to use `free` for memory allocated by `strdup` - } - pointerArray.deallocate() - } - - var currentPtr: UnsafeMutablePointer?> = pointerArray - for element in self { - /// `strdup` allocates memory and copies the C string (inc. null terminator), it returns NULL on allocation failure. - guard let cString: UnsafeMutablePointer = strdup(element) else { - throw LibSessionError.invalidCConversion - } - - allocatedCStrings.append(cString) /// Track for cleanup - currentPtr.pointee = cString /// Store pointer in the array - currentPtr += 1 /// Move to next slot in pointer array - } - - let mutableBuffer = UnsafeBufferPointer(start: pointerArray, count: self.count) - - return try mutableBuffer.withMemoryRebound(to: UnsafePointer?.self) { immutableBuffer in - try body(immutableBuffer) - } - } -} - -public extension Collection where Element == [UInt8]? { - func withUnsafeUInt8CArray( - _ body: (UnsafeBufferPointer?>) throws -> R - ) throws -> R { - let pointerArray = UnsafeMutablePointer?>.allocate(capacity: self.count) - var allocatedByteArrays: [UnsafeMutableRawPointer?] = [] - allocatedByteArrays.reserveCapacity(self.count) - - defer { - for ptr in allocatedByteArrays { - free(ptr) /// Need to use `free` for memory allocated by `malloc` - } - - pointerArray.deallocate() - } - - var currentPtr: UnsafeMutablePointer?> = pointerArray - for maybeBytes in self { - if let bytes = maybeBytes { - guard let allocatedMemory: UnsafeMutableRawPointer = malloc(bytes.count) else { - throw LibSessionError.invalidCConversion - } - - allocatedByteArrays.append(allocatedMemory) /// Track for cleanup - memcpy(allocatedMemory, bytes, bytes.count) /// Copy bytes into the allocated memory - currentPtr.pointee = allocatedMemory.assumingMemoryBound(to: UInt8.self) /// Store in array - } else { - currentPtr.pointee = nil /// Store nil in array - } - currentPtr += 1 /// Move to next slot in pointer array - } - - let mutableBuffer = UnsafeBufferPointer(start: pointerArray, count: self.count) - - return try mutableBuffer.withMemoryRebound(to: UnsafePointer?.self) { immutableBuffer in - try body(immutableBuffer) - } - } -} diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index f0599684bf..8d3b49f97d 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -57,6 +57,86 @@ public extension Array where Element == String { } } +public extension Collection where Element == String { + func withUnsafeCStrArray( + _ body: (UnsafeBufferPointer?>) throws -> R + ) throws -> R { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var pointers: [UnsafePointer?] = [] + pointers.reserveCapacity(self.count) + + for string in self { + let utf8: [CChar] = Array(string.utf8CString) /// Includes null terminator + let buffer = UnsafeMutableBufferPointer.allocate(capacity: utf8.count) + _ = buffer.initialize(from: utf8) + allocatedBuffers.append(buffer) + pointers.append(UnsafePointer(buffer.baseAddress)) + } + + return try pointers.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } +} + +public extension Collection where Element == [UInt8]? { + func withUnsafeUInt8CArray( + _ body: (UnsafeBufferPointer?>) throws -> R + ) throws -> R { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var pointers: [UnsafePointer?] = [] + pointers.reserveCapacity(self.count) + + for maybeBytes in self { + if let bytes: [UInt8] = maybeBytes { + let buffer = UnsafeMutableBufferPointer.allocate(capacity: bytes.count) + _ = buffer.initialize(from: bytes) + allocatedBuffers.append(buffer) + pointers.append(UnsafePointer(buffer.baseAddress)) + } else { + pointers.append(nil) + } + } + + return try pointers.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } +} + +public extension Collection where Element: DataProtocol { + func withUnsafeSpanOfSpans(_ body: (UnsafePointer?, Int) throws -> Result) throws -> Result { + var allocatedBuffers: [UnsafeMutableBufferPointer] = [] + allocatedBuffers.reserveCapacity(self.count) + defer { allocatedBuffers.forEach { $0.deallocate() } } + + var spans: [span_u8] = [] + spans.reserveCapacity(self.count) + + for data in self { + let bytes: [UInt8] = Array(data) + let buffer = UnsafeMutableBufferPointer.allocate(capacity: bytes.count) + _ = buffer.initialize(from: bytes) + allocatedBuffers.append(buffer) + + var span: span_u8 = span_u8() + span.data = buffer.baseAddress + span.size = bytes.count + spans.append(span) + } + + return try spans.withUnsafeBufferPointer { spanBuffer in + try body(spanBuffer.baseAddress, spanBuffer.count) + } + } +} + // MARK: - CAccessible From 1c2f79499c4749e160ec541fc5f75f3e3fd376a7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 13:50:45 +1100 Subject: [PATCH 04/66] Added original handling for community inbox messages --- .../Crypto/Crypto+LibSession.swift | 66 ++++++------------- .../Crypto/Crypto+SessionMessagingKit.swift | 55 ---------------- .../Messages/Message+Origin.swift | 2 +- .../Open Groups/OpenGroupManager.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 22 ++++++- 5 files changed, 42 insertions(+), 105 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 5efe5b5c51..7dab5d95b1 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -160,6 +160,27 @@ public extension Crypto.Generator { return (proto, sender, sentTimestampMs) } + /// Community inbox messages aren't currently handled via the new decode function so we need to custom handle them + // FIXME: Fold into `session_protocol_decode_envelope` once support is added + if case .communityInbox(let posted, _, let serverPublicKey, let senderId, let recipientId) = origin { + let (plaintextWithPadding, sender): (Data, String) = try dependencies[singleton: .crypto].tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: encodedMessage, + senderId: senderId, + recipientId: recipientId, + serverPublicKey: serverPublicKey + ) + ) + + let plaintext = plaintextWithPadding.removePadding() + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + + return (proto, sender, sentTimestampMs) + } + guard case .swarm(let publicKey, let namespace, let serverHash, let serverTimestampMs, let serverExpirationTimestamp) = origin else { throw MessageReceiverError.invalidMessage } @@ -224,51 +245,6 @@ public extension Crypto.Generator { } } } - - static func plaintextForGroupMessage( - groupSessionId: SessionId, - ciphertext: [UInt8] - ) throws -> Crypto.Generator<(plaintext: Data, sender: String)> { - return Crypto.Generator( - id: "plaintextForGroupMessage", - args: [groupSessionId, ciphertext] - ) { dependencies in - return try dependencies.mutate(cache: .libSession) { cache in - guard let config: LibSession.Config = cache.config(for: .groupKeys, sessionId: groupSessionId) else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) - } - guard case .groupKeys(let conf, _, _) = config else { - throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) - } - - var cSessionId: [CChar] = [CChar](repeating: 0, count: 67) - var maybePlaintext: UnsafeMutablePointer? = nil - var plaintextLen: Int = 0 - let didDecrypt: Bool = groups_keys_decrypt_message( - conf, - ciphertext, - ciphertext.count, - &cSessionId, - &maybePlaintext, - &plaintextLen - ) - - // If we got a reported failure then just stop here - guard didDecrypt else { throw MessageReceiverError.decryptionFailed } - - // We need to manually free 'maybePlaintext' upon a successful decryption - defer { free(UnsafeMutableRawPointer(mutating: maybePlaintext)) } - - guard - plaintextLen > 0, - let plaintext: Data = maybePlaintext - .map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } - - return (plaintext, String(cString: cSessionId)) - } ?? { throw MessageReceiverError.decryptionFailed }() - } - } } // MARK: - Groups diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index ddecdb8e83..209a2b727b 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -85,40 +85,6 @@ public extension Crypto.Generator { // MARK: - Decryption public extension Crypto.Generator { - static func plaintextWithSessionProtocol( - ciphertext: Data - ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { - return Crypto.Generator( - id: "plaintextWithSessionProtocol", - args: [ciphertext] - ) { dependencies in - var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) - var maybePlaintext: UnsafeMutablePointer? = nil - var plaintextLen: Int = 0 - - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } - guard - cEd25519SecretKey.count == 64, - session_decrypt_incoming( - &cCiphertext, - cCiphertext.count, - &cEd25519SecretKey, - &cSenderSessionId, - &maybePlaintext, - &plaintextLen - ), - plaintextLen > 0, - let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } - - free(UnsafeMutableRawPointer(mutating: maybePlaintext)) - - return (plaintext, String(cString: cSenderSessionId)) - } - } - static func plaintextWithMultiEncrypt( ciphertext: Data, senderSessionId: SessionId, @@ -152,27 +118,6 @@ public extension Crypto.Generator { } } - static func messageServerHash( - swarmPubkey: String, - namespace: Network.SnodeAPI.Namespace, - data: Data - ) -> Crypto.Generator { - return Crypto.Generator( - id: "messageServerHash", - args: [swarmPubkey, namespace, data] - ) { - let cSwarmPubkey: [CChar] = try swarmPubkey.cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - let cData: [CChar] = try data.base64EncodedString().cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() - var cHash: [CChar] = [CChar](repeating: 0, count: 65) - - guard session_compute_message_hash(cSwarmPubkey, Int16(namespace.rawValue), cData, &cHash) else { - throw MessageReceiverError.decryptionFailed - } - - return String(cString: cHash) - } - } - static func plaintextWithXChaCha20(ciphertext: Data, encKey: [UInt8]) -> Crypto.Generator { return Crypto.Generator( id: "plaintextWithXChaCha20", diff --git a/SessionMessagingKit/Messages/Message+Origin.swift b/SessionMessagingKit/Messages/Message+Origin.swift index d11d280d21..5223db6e37 100644 --- a/SessionMessagingKit/Messages/Message+Origin.swift +++ b/SessionMessagingKit/Messages/Message+Origin.swift @@ -23,7 +23,7 @@ public extension Message { whisperTo: String? ) case communityInbox( - timestamp: TimeInterval, + posted: TimeInterval, messageServerId: Int64, serverPublicKey: String, senderId: String, diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 739174d9aa..7288582fa6 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -728,7 +728,7 @@ public final class OpenGroupManager { let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: messageData, origin: .communityInbox( - timestamp: message.posted, + posted: message.posted, messageServerId: message.id, serverPublicKey: openGroup.publicKey, senderId: message.sender, diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index b9a11f90f2..40cbc1590e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -102,9 +102,25 @@ public enum MessageReceiver { message.openGroupWhisperMods = whisperMods message.openGroupWhisperTo = whisperTo - case .communityInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId): - // TODO: Add this case - fatalError() + case .communityInbox(_, let messageServerId, let serverPublicKey, _, _): + /// Don't process community inbox messages if the sender is blocked + guard + dependencies.mutate(cache: .libSession, { cache in + !cache.isContactBlocked(contactId: sender) + }) || + message.processWithBlockedSender + else { throw MessageReceiverError.senderBlocked } + + /// Ignore self sends if needed + guard message.isSelfSendValid || sender != userSessionId.hexString else { + throw MessageReceiverError.selfSend + } + + threadId = sender + threadVariant = .contact + serverExpirationTimestamp = nil + uniqueIdentifier = "\(messageServerId)" + message.openGroupServerMessageId = UInt64(messageServerId) case .swarm(let publicKey, let namespace, let serverHash, _, let expirationTimestamp): /// Don't process 1-to-1 or group messages if the sender is blocked From 71f303c3d9e5620528e6e407d624c27d81c42957 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 15:58:31 +1100 Subject: [PATCH 05/66] Consolidated MessageReceiverError and MessageSenderError --- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 4 +- .../Crypto/Crypto+LibSession.swift | 8 +- .../Crypto/Crypto+SessionMessagingKit.swift | 8 +- .../Models/MessageDeduplication.swift | 12 +- .../Jobs/AttachmentUploadJob.swift | 2 +- .../Jobs/ConfigMessageReceiveJob.swift | 2 +- .../Jobs/GroupInviteMemberJob.swift | 9 +- .../Jobs/GroupLeavingJob.swift | 8 +- .../Jobs/GroupPromoteMemberJob.swift | 7 +- .../Jobs/MessageReceiveJob.swift | 12 +- SessionMessagingKit/Jobs/MessageSendJob.swift | 15 ++- ...ProcessPendingGroupMemberRemovalsJob.swift | 2 +- .../LibSession+SharedGroup.swift | 2 +- .../LibSession+SessionMessagingKit.swift | 2 +- .../DataExtractionNotification.swift | 13 +- ...roupUpdateDeleteMemberContentMessage.swift | 4 +- .../GroupUpdateInfoChangeMessage.swift | 4 +- .../GroupUpdateInviteMessage.swift | 4 +- .../GroupUpdateMemberChangeMessage.swift | 4 +- .../Control Messages/ReadReceipt.swift | 8 +- .../Control Messages/TypingIndicator.swift | 7 +- .../Control Messages/UnsendRequest.swift | 7 +- .../Messages/LibSessionMessage.swift | 17 ++- SessionMessagingKit/Messages/Message.swift | 32 +++-- .../MessageError.swift} | 114 +++++++++--------- .../Messages/SNProtoContent+Utilities.swift | 4 +- .../VisibleMessage+Attachment.swift | 8 +- .../VisibleMessage+LinkPreview.swift | 5 +- .../VisibleMessage+Profile.swift | 2 +- .../VisibleMessage+Quote.swift | 5 +- .../VisibleMessage+Reaction.swift | 2 +- .../Visible Messages/VisibleMessage.swift | 18 +-- .../Open Groups/Crypto/Crypto+OpenGroup.swift | 8 +- .../Open Groups/OpenGroupManager.swift | 12 +- .../Errors/MessageSenderError.swift | 90 -------------- .../MessageReceiver+Calls.swift | 10 +- ...eReceiver+DataExtractionNotification.swift | 4 +- .../MessageReceiver+ExpirationTimers.swift | 8 +- .../MessageReceiver+Groups.swift | 84 +++++++------ .../MessageReceiver+LibSession.swift | 2 +- .../MessageReceiver+MessageRequests.swift | 6 +- .../MessageReceiver+VisibleMessages.swift | 8 +- .../MessageSender+Groups.swift | 26 ++-- .../Sending & Receiving/MessageReceiver.swift | 39 +++--- .../MessageSender+Convenience.swift | 10 +- .../Sending & Receiving/MessageSender.swift | 29 ++--- .../NotificationsManagerType.swift | 32 ++--- .../Pollers/SwarmPoller.swift | 6 +- .../Utilities/ExtensionHelper.swift | 2 +- .../Crypto/CryptoSMKSpec.swift | 6 +- .../Models/MessageDeduplicationSpec.swift | 10 +- .../LibSession/LibSessionSpec.swift | 4 +- .../Crypto/CryptoOpenGroupSpec.swift | 14 +-- .../MessageReceiverGroupsSpec.swift | 34 +++--- .../NotificationsManagerSpec.swift | 40 +++--- .../NotificationResolution.swift | 8 +- .../NotificationServiceExtension.swift | 72 ++++++----- SessionShareExtension/ThreadPickerVC.swift | 2 +- SessionUtilitiesKit/Crypto/CryptoError.swift | 19 ++- 60 files changed, 457 insertions(+), 491 deletions(-) rename SessionMessagingKit/{Sending & Receiving/Errors/MessageReceiverError.swift => Messages/MessageError.swift} (64%) delete mode 100644 SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5a0adc942f..a96dd7fa8d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -589,6 +589,7 @@ FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; + FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; @@ -1112,8 +1113,6 @@ FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */; }; - FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */; }; - FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; }; FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; }; FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75D280AAF35004C14C5 /* Preferences.swift */; }; FDF222072818CECF000A4995 /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF222062818CECF000A4995 /* ConversationViewModel.swift */; }; @@ -1950,6 +1949,7 @@ FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = ""; }; FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; + FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPollerCache.swift; sourceTree = ""; }; @@ -2405,8 +2405,6 @@ FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyModel.swift; sourceTree = ""; }; FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = ""; }; FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerType.swift; sourceTree = ""; }; - FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverError.swift; sourceTree = ""; }; - FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderError.swift; sourceTree = ""; }; FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Convenience.swift"; sourceTree = ""; }; FDF0B75D280AAF35004C14C5 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; FDF222062818CECF000A4995 /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = ""; }; @@ -3168,6 +3166,7 @@ C352A30825574D8400338F3E /* Message+Destination.swift */, 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */, FDB11A5E2DD5B77800BEF49F /* Message+Origin.swift */, + FD2C68602EA09523000B0E37 /* MessageError.swift */, FDF71EA22B072C2800A8D6B5 /* LibSessionMessage.swift */, FDB11A5C2DD300CF00BEF49F /* SNProtoContent+Utilities.swift */, ); @@ -5201,8 +5200,6 @@ FDF0B7562807F35E004C14C5 /* Errors */ = { isa = PBXGroup; children = ( - FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, - FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, FD09C5EB282B8F17000CE219 /* AttachmentError.swift */, ); path = Errors; @@ -6776,10 +6773,8 @@ FD17D7A227F40F0500122BE0 /* _006_SMK_InitialSetupMigration.swift in Sources */, FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FD2272FE2C352D8E004D8A6C /* LibSession+GroupMembers.swift in Sources */, - FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, - FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD2272712C32911C004D8A6C /* MessageReceiveJob.swift in Sources */, @@ -6828,6 +6823,7 @@ FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */, FDD23AE52E458C940057E853 /* _022_DropSnodeCache.swift in Sources */, + FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, FD2272762C32911C004D8A6C /* ExpirationUpdateJob.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 58c317cf6f..8495da0ca2 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2074,7 +2074,7 @@ extension ConversationVC: guard let openGroupServer: String = cellViewModel.threadOpenGroupServer, dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) - else { throw MessageSenderError.invalidMessage } + else { throw MessageError.invalidMessage("Community does not support reactions") } default: break } @@ -2094,7 +2094,7 @@ extension ConversationVC: let openGroupServer: String = cellViewModel.threadOpenGroupServer, let openGroupRoom: String = openGroupRoom, let pendingChange: OpenGroupManager.PendingChange = pendingChange - else { throw MessageSenderError.invalidMessage } + else { throw MessageError.missingRequiredField } let preparedRequest: Network.PreparedRequest = try { guard !remove else { diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 7dab5d95b1..76b65c8081 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -19,7 +19,7 @@ public extension Crypto.Generator { ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } let cPlaintext: [UInt8] = Array(plaintext) var error: [CChar] = [CChar](repeating: 0, count: 256) @@ -113,8 +113,8 @@ public extension Crypto.Generator { defer { session_protocol_encode_for_destination_free(&result) } guard result.success else { - Log.error(.messageSender, "Failed to encrypt due to error: \(String(cString: error))") - throw MessageSenderError.encryptionFailed + Log.error(.messageSender, "Failed to encode due to error: \(String(cString: error))") + throw MessageError.encodingFailed } return R(UnsafeBufferPointer(start: result.ciphertext.data, count: result.ciphertext.size)) @@ -329,7 +329,7 @@ public extension Crypto.Generator { &subaccount, &subaccountSig, &signature - ) else { throw MessageSenderError.signingFailed } + ) else { throw CryptoError.signatureGenerationFailed } return Authentication.Signature.subaccount( subaccount: subaccount, diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index 209a2b727b..112ec15f41 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -46,7 +46,7 @@ public extension Crypto.Generator { let encryptedData: Data? = cEncryptedDataPtr.map { Data(bytes: $0, count: outLen) } free(UnsafeMutableRawPointer(mutating: cEncryptedDataPtr)) - return try encryptedData ?? { throw MessageSenderError.encryptionFailed }() + return try encryptedData ?? { throw MessageError.encodingFailed }() } } } @@ -73,7 +73,7 @@ public extension Crypto.Generator { ), ciphertextLen > 0, let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } + else { throw MessageError.encodingFailed } free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) @@ -114,7 +114,7 @@ public extension Crypto.Generator { let decryptedData: Data? = cDecryptedDataPtr.map { Data(bytes: $0, count: outLen) } free(UnsafeMutableRawPointer(mutating: cDecryptedDataPtr)) - return try decryptedData ?? { throw MessageReceiverError.decryptionFailed }() + return try decryptedData ?? { throw CryptoError.decryptionFailed }() } } @@ -139,7 +139,7 @@ public extension Crypto.Generator { ), plaintextLen > 0, let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } + else { throw CryptoError.decryptionFailed } free(UnsafeMutableRawPointer(mutating: maybePlaintext)) diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift index e8b5a8f531..e85c8dd062 100644 --- a/SessionMessagingKit/Database/Models/MessageDeduplication.swift +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -228,7 +228,7 @@ public extension MessageDeduplication { threadId: threadId, uniqueIdentifier: uniqueIdentifier ) { - throw MessageReceiverError.duplicateMessage + throw MessageError.duplicateMessage } /// Also check for a dedupe file using the legacy identifier @@ -238,7 +238,7 @@ public extension MessageDeduplication { threadId: threadId, uniqueIdentifier: legacyIdentifier ) { - throw MessageReceiverError.duplicateMessage + throw MessageError.duplicateMessage } } } @@ -338,7 +338,7 @@ public extension MessageDeduplication { using: dependencies ) } - catch { throw MessageReceiverError.duplicatedCall } + catch { throw MessageError.duplicatedCall } } } @@ -354,7 +354,7 @@ public extension MessageDeduplication { /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything /// special then it could result in unexpected behaviours where config messages don't get merged correctly switch processedMessage { - case .config, .invalid: return + case .config: return case .standard(_, let threadVariant, _, let messageInfo, _): try insert( db, @@ -377,7 +377,7 @@ public extension MessageDeduplication { /// We don't actually want to dedupe config messages as `libSession` will take care of that logic and if we do anything /// special then it could result in unexpected behaviours where config messages don't get merged correctly switch processedMessage { - case .config, .invalid: return + case .config: return case .standard: try createDedupeFile( threadId: processedMessage.threadId, @@ -490,7 +490,7 @@ private extension MessageDeduplication { @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? { switch processedMessage { - case .config, .invalid: return nil + case .config: return nil case .standard(_, _, _, let messageInfo, _): guard let timestampMs: UInt64 = messageInfo.message.sentTimestampMs, diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index a3cd20c22a..e316d07f7b 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -184,7 +184,7 @@ public enum AttachmentUploadJob: JobExecutor { threadId: threadId, message: details.message, destination: nil, - error: .other(.cat, "Failed", error), + error: .sendFailure(.cat, "Failed", error), interactionId: interactionId, using: dependencies ) diff --git a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift index 53cd2a73a5..fff1cb0a4a 100644 --- a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift @@ -116,7 +116,7 @@ extension ConfigMessageReceiveJob { self.messages = messages .compactMap { processedMessage -> MessageInfo? in switch processedMessage { - case .standard, .invalid: return nil + case .standard: return nil case .config(_, let namespace, let serverHash, let serverTimestampMs, let data, _): return MessageInfo( namespace: namespace, diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index e652f75f13..12f24b063d 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -141,11 +141,8 @@ public enum GroupInviteMemberJob: JobExecutor { // Register the failure switch error { - case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) - - case SnodeAPIError.rateLimited: - failure(job, error, true) + case is MessageError: failure(job, error, true) + case SnodeAPIError.rateLimited: failure(job, error, true) case SnodeAPIError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") @@ -339,7 +336,7 @@ extension GroupInviteMemberJob { switch authInfo { case .groupMember(_, let authData): self.memberAuthData = authData - default: throw MessageSenderError.invalidMessage + default: throw MessageError.requiredSignatureMissing } } } diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index 6caec66c7c..bee66e1315 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -40,7 +40,7 @@ public enum GroupLeavingJob: JobExecutor { .writePublisher(updates: { db -> RequestType in guard (try? ClosedGroup.exists(db, id: threadId)) == true else { Log.error(.cat, "Failed due to non-existent group") - throw MessageSenderError.invalidClosedGroupUpdate + throw MessageError.invalidGroupUpdate("Could not retrieve group") } let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -86,7 +86,7 @@ public enum GroupLeavingJob: JobExecutor { return .configSync case (.delete, false, _): return .configSync - default: throw MessageSenderError.invalidClosedGroupUpdate + default: throw MessageError.invalidGroupUpdate("Unsupported group leaving configuration") } }) .tryFlatMap { requestType -> AnyPublisher in @@ -138,8 +138,8 @@ public enum GroupLeavingJob: JobExecutor { /// If it failed due to one of these errors then clear out any associated data (as the `SessionThread` exists but /// either the data required to send the `MEMBER_LEFT` message doesn't or the user has had their access to the /// group revoked which would leave the user in a state where they can't leave the group) - switch (error as? MessageSenderError, error as? SnodeAPIError, error as? CryptoError) { - case (.invalidClosedGroupUpdate, _, _), (.noKeyPair, _, _), (.encryptionFailed, _, _), + switch (error as? MessageError, error as? SnodeAPIError, error as? CryptoError) { + case (.invalidGroupUpdate, _, _), (.encodingFailed, _, _), (_, .unauthorised, _), (_, _, .invalidAuthentication): return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index d46639ac78..8beb05cc31 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -136,11 +136,8 @@ public enum GroupPromoteMemberJob: JobExecutor { // Register the failure switch error { - case let senderError as MessageSenderError where !senderError.isRetryable: - failure(job, error, true) - - case SnodeAPIError.rateLimited: - failure(job, error, true) + case is MessageError: failure(job, error, true) + case SnodeAPIError.rateLimited: failure(job, error, true) case SnodeAPIError.clockOutOfSync: Log.error(.cat, "Permanently Failing to send due to clock out of sync issue.") diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index d1b779bd17..129d2d2997 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -86,11 +86,11 @@ public enum MessageReceiveJob: JobExecutor { // excessive logging which isn't useful) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: + MessageError.duplicateMessage, + MessageError.selfSend: break - case let receiverError as MessageReceiverError where !receiverError.isRetryable: + case is MessageError: Log.error(.cat, "Permanently failed message due to error: \(error)") continue @@ -123,9 +123,7 @@ public enum MessageReceiveJob: JobExecutor { case .success(let lastError): /// Report the result of the job switch lastError { - case let error as MessageReceiverError where !error.isRetryable: - failure(updatedJob, error, true) - + case let error as MessageError: failure(updatedJob, error, true) case .some(let error): failure(updatedJob, error, false) case .none: success(updatedJob, false) } @@ -224,7 +222,7 @@ extension MessageReceiveJob { public init(messages: [ProcessedMessage]) { self.messages = messages.compactMap { processedMessage in switch processedMessage { - case .config, .invalid: return nil + case .config: return nil case .standard(_, _, _, let messageInfo, _): return messageInfo } } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index e83b8aca8d..729ad7445e 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -86,12 +86,14 @@ public enum MessageSendJob: JobExecutor { /// Retrieve the current attachment state let attachmentState: AttachmentState = dependencies[singleton: .storage] .read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) } - .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) + .defaulting(to: AttachmentState(error: StorageError.invalidQueryResult)) /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it /// should permanently fail guard attachmentState.error == nil else { - switch (attachmentState.error ?? NetworkError.unknown) { + let finalError: Error = (attachmentState.error ?? NetworkError.unknown) + + switch finalError { case StorageError.objectNotFound: Log.warn(.cat, "Failing \(messageType) (\(job.id ?? -1)) due to missing interaction") @@ -102,7 +104,7 @@ public enum MessageSendJob: JobExecutor { Log.error(.cat, "Failed \(messageType) (\(job.id ?? -1)) due to invalid attachment state") } - return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true) + return failure(job, finalError, true) } /// If we have any pending (or failed) attachment uploads then we should create jobs for them and insert them into the @@ -243,11 +245,8 @@ public enum MessageSendJob: JobExecutor { // Actual error handling switch (error, details.message) { - case (let senderError as MessageSenderError, _) where !senderError.isRetryable: - failure(job, error, true) - - case (SnodeAPIError.rateLimited, _): - failure(job, error, true) + case (is MessageError, _): failure(job, error, true) + case (SnodeAPIError.rateLimited, _): failure(job, error, true) case (SnodeAPIError.clockOutOfSync, _): Log.error(.cat, "\(originalSentTimestampMs != nil ? "Permanently Failing" : "Failing") to send \(messageType) (\(job.id ?? -1)) due to clock out of sync issue.") diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index 607fb81514..6bde4e98ef 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -195,7 +195,7 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { response.allSatisfy({ subResponse in 200...299 ~= ((subResponse as? Network.BatchSubResponse)?.code ?? 400) }) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.invalidGroupUpdate("Failed to remove group member") } return () } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 64a1f5260a..b9a0ac985e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -61,7 +61,7 @@ internal extension LibSession { guard let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate(.ed25519KeyPair()), !dependencies[cache: .general].ed25519SecretKey.isEmpty - else { throw MessageSenderError.noKeyPair } + else { throw CryptoError.missingUserSecretKey } // Prep the relevant details (reduce the members to ensure we don't accidentally insert duplicates) let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 1cadf87630..eb65a9ab0d 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -722,7 +722,7 @@ public extension LibSession { afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? ) throws -> [MergeResult] { guard !messages.isEmpty else { return [] } - guard !swarmPublicKey.isEmpty else { throw MessageReceiverError.noThread } + guard !swarmPublicKey.isEmpty else { throw MessageError.invalidConfigMessageHandling } let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index a1521a47b1..5eae7459ec 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -43,12 +43,17 @@ public final class DataExtractionNotification: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending), let kind = kind else { return false } + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + guard let kind = kind else { throw MessageError.missingRequiredField("kind") } switch kind { - case .screenshot: return true - case .mediaSaved(let timestamp): return timestamp > 0 + case .screenshot: break + case .mediaSaved(let timestamp): + if timestamp == 0 { + throw MessageError.invalidMessage("Timestamp is invalid") + } } } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift index 12c08361d6..f61e82faa1 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateDeleteMemberContentMessage.swift @@ -99,7 +99,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { switch adminSignature { case .some(.standard(let signature)): try container.encode(signature, forKey: .adminSignature) - case .some(.subaccount): throw MessageSenderError.signingFailed + case .some(.subaccount): throw MessageError.requiredSignatureMissing case .none: break // Valid case (member deleting their own sent messages) } } @@ -126,7 +126,7 @@ public final class GroupUpdateDeleteMemberContentMessage: ControlMessage { switch adminSignature { case .some(.standard(let signature)): deleteMemberContentMessageBuilder.setAdminSignature(Data(signature)) - case .some(.subaccount): throw MessageSenderError.signingFailed + case .some(.subaccount): throw MessageError.requiredSignatureMissing case .none: break // Valid case (member deleting their own sent messages) } diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift index 7dbd388901..5385a41afc 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInfoChangeMessage.swift @@ -107,7 +107,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -143,7 +143,7 @@ public final class GroupUpdateInfoChangeMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift index e614131dc8..057d9980da 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateInviteMessage.swift @@ -114,7 +114,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -149,7 +149,7 @@ public final class GroupUpdateInviteMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift index 32689fb1b5..5ec3abcd76 100644 --- a/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/Group Update Messages/GroupUpdateMemberChangeMessage.swift @@ -107,7 +107,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { switch adminSignature { case .standard(let signature): try container.encode(signature, forKey: .adminSignature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } } @@ -143,7 +143,7 @@ public final class GroupUpdateMemberChangeMessage: ControlMessage { adminSignature: try { switch adminSignature { case .standard(let signature): return Data(signature) - case .subaccount: throw MessageSenderError.signingFailed + case .subaccount: throw MessageError.requiredSignatureMissing } }() ) diff --git a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift index dc272f3701..c8ba91a041 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -20,10 +20,10 @@ public final class ReadReceipt: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - if let timestamps = timestamps, !timestamps.isEmpty { return true } - return false + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if timestamps?.isEmpty != false { throw MessageError.missingRequiredField("timestamps") } } // MARK: - Codable diff --git a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift index 7bd5844223..2087f9a319 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -42,9 +42,10 @@ public final class TypingIndicator: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - return kind != nil + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if kind == nil { throw MessageError.missingRequiredField("kind") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index 88b84364f5..a0c433d647 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -16,10 +16,11 @@ public final class UnsendRequest: ControlMessage { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) - return timestamp != nil && author != nil + if (timestamp ?? 0) == 0 { throw MessageError.missingRequiredField("timestamp") } + if author?.isEmpty != false { throw MessageError.missingRequiredField("author") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/LibSessionMessage.swift b/SessionMessagingKit/Messages/LibSessionMessage.swift index a5f552161b..dff9b9cfb5 100644 --- a/SessionMessagingKit/Messages/LibSessionMessage.swift +++ b/SessionMessagingKit/Messages/LibSessionMessage.swift @@ -12,8 +12,10 @@ public final class LibSessionMessage: Message, NotProtoConvertible { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - return !ciphertext.isEmpty + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + if ciphertext.isEmpty { throw MessageError.missingRequiredField("ciphertext") } } // MARK: - Initialization @@ -52,7 +54,7 @@ public extension LibSessionMessage { guard let sessionId: SessionId = try? SessionId(from: memberId), let groupKeysGenData: Data = "\(groupKeysGen)".data(using: .ascii) - else { throw MessageSenderError.encryptionFailed } + else { throw MessageError.invalidMessage("Unable to generate group kicked message") } return (sessionId, Data(sessionId.publicKey.appending(contentsOf: Array(groupKeysGenData)))) } @@ -68,7 +70,7 @@ public extension LibSessionMessage { encoding: .ascii ), let currentGen: Int = Int(currentGenString, radix: 10) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.decodingFailed } return (SessionId(.standard, publicKey: Array(plaintext[0..= currentKeysGen - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.ignorableMessage } } } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index be0582d7d8..594760367f 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -49,19 +49,18 @@ public class Message: Codable { // MARK: - Validation - public func isValid(isSending: Bool) -> Bool { + public func validateMessage(isSending: Bool) throws { guard let sentTimestampMs: UInt64 = sentTimestampMs, - sentTimestampMs > 0, - sender != nil - else { return false } + sentTimestampMs > 0 + else { throw MessageError.missingRequiredField("sentTimestampMs") } + if sender?.isEmpty != false { throw MessageError.missingRequiredField("sender") } /// If this is an incoming message then ensure we also have a received timestamp if !isSending { - guard - let receivedTimestampMs: UInt64 = receivedTimestampMs, - receivedTimestampMs > 0 - else { return false } + if (receivedTimestampMs ?? 0) == 0 { + throw MessageError.missingRequiredField("receivedTimestampMs") + } } /// We added a new `sigTimestampMs` which is included in the message data so can be verified as part of the signature @@ -75,7 +74,9 @@ public class Message: Codable { /// at, due to this we need to allow for some variation between the values switch (isSending, sigTimestampMs, openGroupServerMessageId) { case (_, .some(let sigTimestampMs), .none), (true, .some(let sigTimestampMs), .some): - return (sigTimestampMs == sentTimestampMs) + if sigTimestampMs != sentTimestampMs { + throw MessageError.invalidMessage("Signature timestamp doesn't match sent timestamp") + } /// Outgoing messages to a community should have matching `sigTimestampMs` and `sentTimestampMs` /// values as they are set locally, when we get a response from the community we update the `sentTimestampMs` to @@ -83,10 +84,12 @@ public class Message: Codable { case (false, .some(let sigTimestampMs), .some): let delta: TimeInterval = (TimeInterval(max(sigTimestampMs, sentTimestampMs) - min(sigTimestampMs, sentTimestampMs)) / 1000) - return delta < Network.SOGS.validTimestampVarianceThreshold + if delta > Network.SOGS.validTimestampVarianceThreshold { + throw MessageError.invalidMessage("Difference between signature timestamp and sent timestamp is too large") + } // FIXME: We want to remove support for this case in a future release - case (_, .none, _): return true + case (_, .none, _): break } } @@ -180,13 +183,11 @@ public enum ProcessedMessage { data: Data, uniqueIdentifier: String ) - case invalid public var threadId: String { switch self { case .standard(let threadId, _, _, _, _): return threadId case .config(let publicKey, _, _, _, _, _): return publicKey - case .invalid: return "" } } @@ -200,7 +201,6 @@ public enum ProcessedMessage { } case .config(_, let namespace, _, _, _, _): return namespace - case .invalid: return .default } } @@ -208,7 +208,6 @@ public enum ProcessedMessage { switch self { case .standard(_, _, _, _, let uniqueIdentifier): return uniqueIdentifier case .config(_, _, _, _, _, let uniqueIdentifier): return uniqueIdentifier - case .invalid: return "" } } @@ -216,7 +215,6 @@ public enum ProcessedMessage { switch self { case .standard: return false case .config: return true - case .invalid: return false } } } @@ -371,7 +369,7 @@ public extension Message { return variant.messageType.fromProto(proto, sender: sender, using: dependencies) } - return try decodedMessage ?? { throw MessageReceiverError.unknownMessage(proto) }() + return try decodedMessage ?? { throw MessageError.unknownMessage(proto) }() } static func shouldSync(message: Message) -> Bool { diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Messages/MessageError.swift similarity index 64% rename from SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift rename to SessionMessagingKit/Messages/MessageError.swift index d38d087df0..f6a1f1329f 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Messages/MessageError.swift @@ -1,64 +1,68 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. // // stringlint:disable import Foundation +import SessionUtilitiesKit -public enum MessageReceiverError: Error, CustomStringConvertible { +public enum MessageError: Error, CustomStringConvertible { + case encodingFailed + case decodingFailed + case invalidMessage(String) + case missingRequiredField(String?) + case duplicateMessage - case invalidMessage - case invalidSender - case unknownMessage(SNProtoContent?) - case unknownEnvelopeType - case noUserX25519KeyPair - case noUserED25519KeyPair - case invalidSignature - case noData - case senderBlocked - case noThread - case selfSend - case decryptionFailed - case noGroupKeyPair - case invalidConfigMessageHandling + case duplicatedCall case outdatedMessage case ignorableMessage case ignorableMessageRequestMessage - case duplicatedCall - case missingRequiredAdminPrivileges case deprecatedMessage - case failedToProcess + case protoConversionFailed + case unknownMessage(SNProtoContent?) + + case requiredSignatureMissing + case invalidConfigMessageHandling + case invalidRevokedRetrievalMessageHandling + case invalidGroupUpdate(String) case communitiesDoNotSupportControlMessages - - public var isRetryable: Bool { - switch self { - case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, - .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, - .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, - .missingRequiredAdminPrivileges, .failedToProcess, .communitiesDoNotSupportControlMessages: - return false - - default: return true - } - } + case requiresGroupId(String) + case requiresGroupIdentityPrivateKey + + case selfSend + case invalidSender + case senderBlocked + case messageRequiresThreadToExistButThreadDoesNotExist + case sendFailure(Log.Category?, String, Error) + + public static let missingRequiredField: MessageError = .missingRequiredField(nil) public var shouldUpdateLastHash: Bool { switch self { - // If we get one of these errors then we still want to update the last hash to prevent - // retrieving and attempting to process the same messages again (as well as ensure the - // next poll doesn't retrieve the same message - these errors are essentially considered - // "already successfully processed") - case .selfSend, .duplicateMessage, .outdatedMessage, .missingRequiredAdminPrivileges: + /// If we get one of these errors then we still want to update the last hash to prevent retrieving and attempting to process + /// the same messages again (as well as ensure the next poll doesn't retrieve the same message - these errors are essentially + /// considered "already successfully processed") + case .duplicateMessage, .duplicatedCall, .outdatedMessage, .selfSend: return true default: return false } } - + public var description: String { switch self { + case .encodingFailed: return "Failed to encode message." + case .decodingFailed: return "Failed to decode message." + case .invalidMessage(let reason): return "Invalid message (\(reason))." + case .missingRequiredField(let field): + return "Message missing required field\(field.map { ": \($0)" } ?? "")." + case .duplicateMessage: return "Duplicate message." - case .invalidMessage: return "Invalid message." - case .invalidSender: return "Invalid sender." + case .duplicatedCall: return "Duplicate call." + case .outdatedMessage: return "Message was sent before a config change which would have removed the message." + case .ignorableMessage: return "Message should be ignored." + case .ignorableMessageRequestMessage: return "Message request message should be ignored." + case .deprecatedMessage: return "This message type has been deprecated." + case .protoConversionFailed: return "Failed to convert to protobuf message." case .unknownMessage(let content): switch content { case .none: return "Unknown message type (no content)." @@ -93,29 +97,21 @@ public enum MessageReceiverError: Error, CustomStringConvertible { .joined(separator: ", ") return "Unknown message type (\(protoInfoString))." } - - case .unknownEnvelopeType: return "Unknown envelope type." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair." - case .invalidSignature: return "Invalid message signature." - case .noData: return "Received an empty envelope." - case .senderBlocked: return "Received a message from a blocked user." - case .noThread: return "Couldn't find thread for message." - case .selfSend: return "Message addressed at self." - case .decryptionFailed: return "Decryption failed." - // Shared sender keys - case .noGroupKeyPair: return "Missing group key pair." - + case .requiredSignatureMissing: return "Required signature missing." case .invalidConfigMessageHandling: return "Invalid handling of a config message." - case .outdatedMessage: return "Message was sent before a config change which would have removed the message." - case .ignorableMessage: return "Message should be ignored." - case .ignorableMessageRequestMessage: return "Message request message should be ignored." - case .duplicatedCall: return "Duplicate call." - case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." - case .deprecatedMessage: return "This message type has been deprecated." - case .failedToProcess: return "Failed to process." + case .invalidRevokedRetrievalMessageHandling: return "Invalid handling of a revoked retrieval message." + case .invalidGroupUpdate(let reason): return "Invalid group update (\(reason))." case .communitiesDoNotSupportControlMessages: return "Communities do not support control messages." + case .requiresGroupId(let id): return "Required group id but was given: \(id)" + case .requiresGroupIdentityPrivateKey: return "Requires group identity private key" + + case .selfSend: return "Message addressed at self." + case .invalidSender: return "Invalid sender." + case .senderBlocked: return "Received a message from a blocked user." + + case .messageRequiresThreadToExistButThreadDoesNotExist: return "Message requires a thread to exist before processing the message but the thread does not exist." + case .sendFailure(_, _, let error): return "\(error)" } } } diff --git a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift index e4fc9e9510..24d954445a 100644 --- a/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift +++ b/SessionMessagingKit/Messages/SNProtoContent+Utilities.swift @@ -30,12 +30,12 @@ public extension SNProtoContent { /// We need to ensure we don't send a message which should have uploaded files but hasn't, we do this by comparing the /// `attachmentIds` on the `VisibleMessage` to the `attachments` value guard expectedAttachmentUploadCount == (attachments?.count ?? 0) else { - throw MessageSenderError.attachmentsNotUploaded + throw MessageError.invalidMessage("Attachments not uploaded") } /// Ensure we haven't incorrectly included the `linkPreview` or `quote` attachments in the main `attachmentIds` guard uniqueAttachmentIds.count == expectedAttachmentUploadCount else { - throw MessageSenderError.attachmentsInvalid + throw MessageError.invalidMessage("Incorrect attachment count") } do { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift index 00e8327e19..04346dbff9 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift @@ -21,9 +21,13 @@ public extension VisibleMessage { public var sizeInBytes: UInt? public var url: String? - public func isValid(isSending: Bool) -> Bool { + public func validateMessage(isSending: Bool) throws { // key and digest can be nil for open group attachments - contentType != nil && kind != nil && size != nil && sizeInBytes != nil && url != nil + if contentType?.isEmpty != false { throw MessageError.invalidMessage("contentType") } + if kind == nil { throw MessageError.invalidMessage("kind") } + if (size ?? .zero) == .zero { throw MessageError.invalidMessage("size") } + if (sizeInBytes ?? 0) == 0 { throw MessageError.invalidMessage("sizeInBytes") } + if url?.isEmpty != false { throw MessageError.invalidMessage("url") } } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 3d64e8e482..868654cafe 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -9,7 +9,10 @@ public extension VisibleMessage { public let url: String? public let attachmentId: String? - public func isValid(isSending: Bool) -> Bool { title != nil && url != nil && attachmentId != nil } + public func validateMessage(isSending: Bool) throws { + if title?.isEmpty != false { throw MessageError.invalidMessage("title") } + if url?.isEmpty != false { throw MessageError.invalidMessage("url") } + } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index d3eccefb6f..f259c685a6 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -53,7 +53,7 @@ public extension VisibleMessage { } public func toProtoBuilder() throws -> SNProtoDataMessage.SNProtoDataMessageBuilder { - guard let displayName = displayName else { throw MessageSenderError.protoConversionFailed } + guard let displayName = displayName else { throw MessageError.protoConversionFailed } let dataMessageProto = SNProtoDataMessage.builder() let profileProto = SNProtoLokiProfile.builder() diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index d37de1670a..f3529dc0c0 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -10,7 +10,10 @@ public extension VisibleMessage { public let authorId: String? public let text: String? - public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } + public func validateMessage(isSending: Bool) throws { + if (timestamp ?? 0) == 0 { throw MessageError.invalidMessage("timestamp") } + if authorId?.isEmpty != false { throw MessageError.invalidMessage("authorId") } + } // MARK: - Initialization diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift index 7f7b3e63b1..7b6fdd8f66 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Reaction.swift @@ -17,7 +17,7 @@ public extension VisibleMessage { /// This is the behaviour for the reaction public var kind: Kind - public func isValid(isSending: Bool) -> Bool { true } + public func validateMessage(isSending: Bool) throws {} // MARK: - Kind diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 6fe8111fdc..dc62956bc2 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -33,13 +33,17 @@ public final class VisibleMessage: Message { // MARK: - Validation - public override func isValid(isSending: Bool) -> Bool { - guard super.isValid(isSending: isSending) else { return false } - if !attachmentIds.isEmpty || dataMessageHasAttachments == true { return true } - if openGroupInvitation != nil { return true } - if reaction != nil { return true } - if let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return true } - return false + public override func validateMessage(isSending: Bool) throws { + try super.validateMessage(isSending: isSending) + + let hasAttachments: Bool = (!attachmentIds.isEmpty || dataMessageHasAttachments == true) + let hasOpenGroupInvitation: Bool = (openGroupInvitation != nil) + let hasReaction: Bool = (reaction != nil) + let hasText: Bool = (text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + + if !hasAttachments && !hasOpenGroupInvitation && !hasReaction && !hasText { + throw MessageError.invalidMessage("Has no content") + } } // MARK: - Initialization diff --git a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift index e7aff6b058..c447dadc73 100644 --- a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift +++ b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroup.swift @@ -9,8 +9,8 @@ import SessionUtilitiesKit // MARK: - Messages public extension Crypto.Generator { - static func plaintextWithSessionBlindingProtocol( - ciphertext: Data, + static func plaintextWithSessionBlindingProtocol( + ciphertext: I, senderId: String, recipientId: String, serverPublicKey: String @@ -28,7 +28,7 @@ public extension Crypto.Generator { var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 - guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, @@ -45,7 +45,7 @@ public extension Crypto.Generator { ), plaintextLen > 0, let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.decodingFailed } free(UnsafeMutableRawPointer(mutating: maybePlaintext)) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 7288582fa6..eecb2c6be2 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -588,7 +588,7 @@ public final class OpenGroupManager { ) switch processedMessage { - case .config, .invalid: break + case .config: break case .standard(_, _, _, let messageInfo, _): insertedInteractionInfo.append( try MessageReceiver.handle( @@ -611,8 +611,8 @@ public final class OpenGroupManager { // them as there will be a lot since we each service node duplicates messages) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: + MessageError.duplicateMessage, + MessageError.selfSend: break default: Log.error(.openGroup, "Couldn't receive open group message due to error: \(error).") @@ -744,7 +744,7 @@ public final class OpenGroupManager { ) switch processedMessage { - case .config, .invalid: break + case .config: break case .standard(let threadId, _, let proto, let messageInfo, _): /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the /// "expensive" lookup when possible @@ -810,8 +810,8 @@ public final class OpenGroupManager { // whenever we send a message so this ends up being spam otherwise) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: + MessageError.duplicateMessage, + MessageError.selfSend: break default: diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift deleted file mode 100644 index 0aac85b8ed..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -public enum MessageSenderError: Error, CustomStringConvertible, Equatable { - case invalidMessage - case protoConversionFailed - case noUserX25519KeyPair - case noUserED25519KeyPair - case signingFailed - case encryptionFailed - case noUsername - case attachmentsNotUploaded - case attachmentsInvalid - case blindingFailed - case invalidDestination - - // Closed groups - case noThread - case noKeyPair - case invalidClosedGroupUpdate - case invalidConfigMessageHandling - case deprecatedLegacyGroup - - case other(Log.Category?, String, Error) - - internal var isRetryable: Bool { - switch self { - case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, - .signingFailed, .encryptionFailed, .blindingFailed: - return false - - default: return true - } - } - - public var description: String { - switch self { - case .invalidMessage: return "Invalid message (MessageSenderError.invalidMessage)." - case .protoConversionFailed: return "Couldn't convert message to proto (MessageSenderError.protoConversionFailed)." - case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair (MessageSenderError.noUserX25519KeyPair)." - case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair (MessageSenderError.noUserED25519KeyPair)." - case .signingFailed: return "Couldn't sign message (MessageSenderError.signingFailed)." - case .encryptionFailed: return "Couldn't encrypt message (MessageSenderError.encryptionFailed)." - case .noUsername: return "Missing username (MessageSenderError.noUsername)." - case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded (MessageSenderError.attachmentsNotUploaded)." - case .attachmentsInvalid: return "Attachments Invalid (MessageSenderError.attachmentsInvalid)." - case .blindingFailed: return "Couldn't blind the sender (MessageSenderError.blindingFailed)." - case .invalidDestination: return "Invalid destination (MessageSenderError.invalidDestination)." - - // Closed groups - case .noThread: return "Couldn't find a thread associated with the given group public key (MessageSenderError.noThread)." - case .noKeyPair: return "Couldn't find a private key associated with the given group public key (MessageSenderError.noKeyPair)." - case .invalidClosedGroupUpdate: return "Invalid group update (MessageSenderError.invalidClosedGroupUpdate)." - case .invalidConfigMessageHandling: return "Invalid handling of a config message (MessageSenderError.invalidConfigMessageHandling)." - case .deprecatedLegacyGroup: return "Tried to send a message for a deprecated legacy group (MessageSenderError.deprecatedLegacyGroup)." - case .other(_, _, let error): return "\(error)" - } - } - - public static func == (lhs: MessageSenderError, rhs: MessageSenderError) -> Bool { - switch (lhs, rhs) { - case (.invalidMessage, .invalidMessage): return true - case (.protoConversionFailed, .protoConversionFailed): return true - case (.noUserX25519KeyPair, .noUserX25519KeyPair): return true - case (.noUserED25519KeyPair, .noUserED25519KeyPair): return true - case (.signingFailed, .signingFailed): return true - case (.encryptionFailed, .encryptionFailed): return true - case (.noUsername, .noUsername): return true - case (.attachmentsNotUploaded, .attachmentsNotUploaded): return true - case (.noThread, .noThread): return true - case (.noKeyPair, .noKeyPair): return true - case (.invalidClosedGroupUpdate, .invalidClosedGroupUpdate): return true - case (.deprecatedLegacyGroup, .deprecatedLegacyGroup): return true - case (.blindingFailed, .blindingFailed): return true - - case (.other(_, let lhsDescription, let lhsError), .other(_, let rhsDescription, let rhsError)): - // Not ideal but the best we can do - return ( - lhsDescription == rhsDescription && - "\(lhsError)" == "\(rhsError)" - ) - - default: return false - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index cdd2921bcf..7fdff412e7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -25,7 +25,9 @@ extension MessageReceiver { using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { // Only support calls from contact threads - guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } + guard threadVariant == .contact else { + throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") + } switch (message.kind, message.state) { case (.preOffer, _): @@ -89,7 +91,7 @@ extension MessageReceiver { dependencies.mutate(cache: .libSession, { cache in !cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.missingRequiredField } guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { // Add missed call message for call offer messages from more than one minute Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)") @@ -364,7 +366,7 @@ extension MessageReceiver { dependencies.mutate(cache: .libSession, { cache in !cache.isMessageRequest(threadId: caller, threadVariant: threadVariant) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.missingRequiredField } let messageSentTimestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? @@ -456,7 +458,7 @@ extension MessageReceiver { .filter(Interaction.Columns.messageUuid == message.uuid) .isEmpty(db) ).defaulting(to: false) - else { throw MessageReceiverError.duplicatedCall } + else { throw MessageError.duplicatedCall } guard let sender: String = message.sender, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 4c5f597204..871968e0c2 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -18,10 +18,10 @@ extension MessageReceiver { threadVariant == .contact, let sender: String = message.sender, let messageKind: DataExtractionNotification.Kind = message.kind - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Message missing required fields") } /// We no longer support the old screenshot notification - guard messageKind != .screenshot else { throw MessageReceiverError.deprecatedMessage } + guard messageKind != .screenshot else { throw MessageError.deprecatedMessage } let timestampMs: Int64 = ( message.sentTimestampMs.map { Int64($0) } ?? diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index 51d22cfba3..80739f5e08 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -15,12 +15,14 @@ extension MessageReceiver { proto: SNProtoContent, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard proto.hasExpirationType || proto.hasExpirationTimer else { throw MessageReceiverError.invalidMessage } + guard proto.hasExpirationType || proto.hasExpirationTimer else { + throw MessageError.invalidMessage("Message missing required fields") + } guard threadVariant == .contact, // Groups are handled via the GROUP_INFO config instead let sender: String = message.sender, let timestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Message missing required fields") } let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) @@ -42,7 +44,7 @@ extension MessageReceiver { // If the updated config from this message is different from local config, // this control message should already be removed. if threadId == dependencies[cache: .general].sessionId.hexString && updatedConfig != localConfig { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } return try updatedConfig.insertControlMessage( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 4de5e3bcb9..9fd2b8783a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -87,7 +87,7 @@ extension MessageReceiver { ) return nil - default: throw MessageReceiverError.invalidMessage + default: throw MessageError.invalidMessage("Attempted to handle unexpected message as group update message: \(type(of: message))") } } @@ -99,8 +99,11 @@ extension MessageReceiver { ) throws { let userSessionId: SessionId = dependencies[cache: .general].sessionId + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } + guard - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: message.groupSessionId.publicKey, @@ -119,7 +122,7 @@ extension MessageReceiver { memberAuthData: message.memberAuthData ) ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to validate group invite") } } // MARK: - Specific Handling @@ -130,10 +133,10 @@ extension MessageReceiver { suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } // Ensure the message is valid try validateGroupInvite(message: message, using: dependencies) @@ -232,14 +235,14 @@ extension MessageReceiver { suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, - let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: Array(message.groupIdentitySeed)) - ) - else { throw MessageReceiverError.invalidMessage } + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } + let groupIdentityKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate( + .ed25519KeyPair(seed: Array(message.groupIdentitySeed)) + ) let groupSessionId: SessionId = SessionId(.group, publicKey: groupIdentityKeyPair.publicKey) // Update profile if needed @@ -324,9 +327,11 @@ extension MessageReceiver { serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, @@ -336,7 +341,7 @@ extension MessageReceiver { ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group info change message") } // Add a record of the specific change to the conversation (the actual change is handled via // config messages so these are only for record purposes) @@ -416,9 +421,11 @@ extension MessageReceiver { serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, @@ -428,7 +435,7 @@ extension MessageReceiver { ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group member change message") } let userSessionId: SessionId = dependencies[cache: .general].sessionId let profiles: [String: Profile] = (try? Profile @@ -519,13 +526,15 @@ extension MessageReceiver { ) throws { // If the user is a group admin then we need to remove the member from the group, we already have a // "member left" message so `sendMemberChangedMessage` should be `false` + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, dependencies.mutate(cache: .libSession, { cache in cache.isAdmin(groupSessionId: groupSessionId) }) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.ignorableMessage } // Trigger this removal in a separate process because it requires a number of requests to be made db.afterCommit { @@ -550,10 +559,10 @@ extension MessageReceiver { serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageReceiverError.invalidMessage } + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } // Add a record of the specific change to the conversation (the actual change is handled via // config messages so these are only for record purposes) @@ -597,11 +606,13 @@ extension MessageReceiver { message: GroupUpdateInviteResponseMessage, using dependencies: Dependencies ) throws { - guard - let sender: String = message.sender, - let sentTimestampMs: UInt64 = message.sentTimestampMs, - message.isApproved // Only process the invite response if it was an approval - else { throw MessageReceiverError.invalidMessage } + guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } + + // Only process the invite response if it was an approval + guard message.isApproved else { throw MessageError.ignorableMessage } // Update profile if needed if let profile = message.profile { @@ -642,7 +653,9 @@ extension MessageReceiver { message: GroupUpdateDeleteMemberContentMessage, using dependencies: Dependencies ) throws { - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { throw MessageReceiverError.invalidMessage } + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } let interactionIdsToRemove: [Int64] let explicitHashesToRemove: [String] @@ -663,7 +676,7 @@ extension MessageReceiver { ), using: dependencies ) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Unable to verify group delete member content message") } /// Find all relevant interactions to remove let interactionIdsForRemovedHashes: [Int64] = try Interaction @@ -719,7 +732,8 @@ extension MessageReceiver { .asRequest(of: String.self) .fetchAll(db) - case (.none, .none, _): throw MessageReceiverError.invalidMessage + case (.none, .none, _): + throw MessageError.invalidMessage("Invalid group delete member content message configuration") } /// Retrieve the hashes which should be deleted first (these will be removed from the local diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift index f209de30ed..587493c4ff 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift @@ -24,7 +24,7 @@ extension MessageReceiver { guard let sender: String = message.sender, let senderSessionId: SessionId = try? SessionId(from: sender) - else { throw MessageReceiverError.decryptionFailed } + else { throw MessageError.invalidSender } let supportedEncryptionDomains: [LibSession.Crypto.Domain] = [ .kickedMessage diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index aa966d9d39..35482a362c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -18,10 +18,8 @@ extension MessageReceiver { var blindedContactIds: [String] = [] // Ignore messages which were sent from the current user - guard - message.sender != userSessionId.hexString, - let senderId: String = message.sender - else { throw MessageReceiverError.invalidMessage } + guard message.sender != userSessionId.hexString else { throw MessageError.ignorableMessage } + guard let senderId: String = message.sender else { throw MessageError.missingRequiredField("sender") } // Update profile if needed (want to do this regardless of whether the message exists or // not to ensure the profile info gets sync between a users devices at every chance) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 430b38e325..d6589b9d52 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -26,7 +26,7 @@ extension MessageReceiver { using dependencies: Dependencies ) throws -> InsertedInteractionInfo { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { - throw MessageReceiverError.invalidMessage + throw MessageError.missingRequiredField } // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to @@ -55,13 +55,13 @@ extension MessageReceiver { case .community: // Only process visible messages for communities if they have an existing thread guard (try? SessionThread.exists(db, id: threadId)) == true else { - throw MessageReceiverError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } case .legacyGroup, .group: // Only process visible messages for groups if they have a ClosedGroup record guard (try? ClosedGroup.exists(db, id: threadId)) == true else { - throw MessageReceiverError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } } @@ -116,7 +116,7 @@ extension MessageReceiver { case .group, .versionBlinded07: Log.info(.messageReceiver, "Ignoring message with invalid sender.") - throw MessageReceiverError.invalidSender + throw MessageError.invalidSender } }() let generateCurrentUserSessionIds: () -> Set = { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 479f4af263..b597fe2167 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -255,7 +255,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -263,7 +263,7 @@ extension MessageSender { guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: sessionId.hexString), let groupIdentityPrivateKey: Data = closedGroup.groupIdentityPrivateKey - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let userSessionId: SessionId = dependencies[cache: .general].sessionId let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -359,7 +359,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -370,7 +370,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let userSessionId: SessionId = dependencies[cache: .general].sessionId let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -399,7 +399,7 @@ extension MessageSender { using: dependencies ) - default: throw MessageSenderError.invalidClosedGroupUpdate + default: throw MessageError.invalidGroupUpdate("Invalid display picture update provided: \(displayPictureUpdate)") } } } @@ -461,7 +461,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -472,7 +472,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -548,7 +548,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } typealias MemberJobData = ( @@ -570,7 +570,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? @@ -789,7 +789,7 @@ extension MessageSender { using dependencies: Dependencies ) -> AnyPublisher { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + return Fail(error: MessageError.requiresGroupId(groupSessionId)).eraseToAnyPublisher() } return dependencies[singleton: .storage] @@ -800,7 +800,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var maybeSupplementalKeyRequest: Network.PreparedRequest? @@ -981,7 +981,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } /// Perform the config changes without triggering a config sync (we will do so manually after the process completes) try dependencies.mutate(cache: .libSession) { cache in @@ -1103,7 +1103,7 @@ extension MessageSender { .select(.groupIdentityPrivateKey) .asRequest(of: Data.self) .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } + else { throw MessageError.requiresGroupIdentityPrivateKey } /// Determine which members actually need to be promoted (rather than just resent promotions) let memberIds: Set = Set(members.map { id, _ in id }) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 40cbc1590e..a8065a714d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -25,7 +25,7 @@ public enum MessageReceiver { /// Config messages are custom-handled internally within `libSession` so just return the data directly guard !origin.isConfigNamespace else { guard case .swarm(let publicKey, let namespace, let serverHash, let timestampMs, _) = origin else { - throw MessageReceiverError.invalidConfigMessageHandling + throw MessageError.invalidConfigMessageHandling } return .config( @@ -41,7 +41,7 @@ public enum MessageReceiver { /// The group "revoked retrievable" namespace uses custom encryption so we need to custom handle it guard !origin.isRevokedRetrievableNamespace else { guard case .swarm(let publicKey, _, let serverHash, _, let serverExpirationTimestamp) = origin else { - throw MessageReceiverError.invalidMessage + throw MessageError.invalidRevokedRetrievalMessageHandling } let proto: SNProtoContent = try SNProtoContent.builder().build() @@ -55,9 +55,7 @@ public enum MessageReceiver { proto: proto, messageInfo: try MessageReceiveJob.Details.MessageInfo( message: message, - variant: try Message.Variant(from: message) ?? { - throw MessageReceiverError.invalidMessage - }(), + variant: .libSessionMessage, threadVariant: .group, serverExpirationTimestamp: serverExpirationTimestamp, proto: proto @@ -90,7 +88,7 @@ public enum MessageReceiver { case .community(let openGroupId, _, _, let messageServerId, let whisper, let whisperMods, let whisperTo): /// Don't allow control messages in community conversations guard message is VisibleMessage else { - throw MessageReceiverError.communitiesDoNotSupportControlMessages + throw MessageError.communitiesDoNotSupportControlMessages } threadId = openGroupId @@ -102,18 +100,18 @@ public enum MessageReceiver { message.openGroupWhisperMods = whisperMods message.openGroupWhisperTo = whisperTo - case .communityInbox(_, let messageServerId, let serverPublicKey, _, _): + case .communityInbox(_, let messageServerId, _, _, _): /// Don't process community inbox messages if the sender is blocked guard dependencies.mutate(cache: .libSession, { cache in !cache.isContactBlocked(contactId: sender) }) || message.processWithBlockedSender - else { throw MessageReceiverError.senderBlocked } + else { throw MessageError.senderBlocked } /// Ignore self sends if needed guard message.isSelfSendValid || sender != userSessionId.hexString else { - throw MessageReceiverError.selfSend + throw MessageError.selfSend } threadId = sender @@ -129,11 +127,11 @@ public enum MessageReceiver { !cache.isContactBlocked(contactId: sender) }) || message.processWithBlockedSender - else { throw MessageReceiverError.senderBlocked } + else { throw MessageError.senderBlocked } /// Ignore self sends if needed guard message.isSelfSendValid || sender != userSessionId.hexString else { - throw MessageReceiverError.selfSend + throw MessageError.selfSend } switch namespace { @@ -151,7 +149,7 @@ public enum MessageReceiver { default: Log.warn(.messageReceiver, "Couldn't process message due to invalid namespace.") - throw MessageReceiverError.unknownMessage(proto) + throw MessageError.unknownMessage(proto) } serverExpirationTimestamp = expirationTimestamp @@ -161,9 +159,7 @@ public enum MessageReceiver { } /// Ensure the message is valid - guard message.isValid(isSending: false) else { - throw MessageReceiverError.invalidMessage - } + try message.validateMessage(isSending: false) return .standard( threadId: threadId, @@ -172,7 +168,7 @@ public enum MessageReceiver { messageInfo: try MessageReceiveJob.Details.MessageInfo( message: message, variant: try Message.Variant(from: message) ?? { - throw MessageReceiverError.invalidMessage + throw MessageError.invalidMessage("Unknown message type: \(type(of: message))") }(), threadVariant: threadVariant, serverExpirationTimestamp: serverExpirationTimestamp, @@ -248,6 +244,7 @@ public enum MessageReceiver { threadVariant: threadVariant, message: message, serverExpirationTimestamp: serverExpirationTimestamp, + associatedWithProto: proto, suppressNotifications: suppressNotifications, using: dependencies ) @@ -322,7 +319,7 @@ public enum MessageReceiver { using: dependencies ) - default: throw MessageReceiverError.unknownMessage(proto) + default: throw MessageError.unknownMessage(proto) } // Perform any required post-handling logic @@ -427,7 +424,7 @@ public enum MessageReceiver { .filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId) .asRequest(of: Info.self) .fetchOne(db) - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Could not find message reaction is associated to") } // If the user locally deleted the message then we don't want to process reactions for it guard !interactionInfo.variant.isDeletedMessage else { return } @@ -483,7 +480,7 @@ public enum MessageReceiver { cache.hasCredentials(groupSessionId: groupSessionId), !cache.groupIsDestroyed(groupSessionId: groupSessionId), !cache.wasKickedFromGroup(groupSessionId: groupSessionId) - else { throw MessageReceiverError.outdatedMessage } + else { throw MessageError.outdatedMessage } return } @@ -502,7 +499,7 @@ public enum MessageReceiver { (message as? VisibleMessage)?.dataMessageHasAttachments == false || messageSentTimestamp > deleteAttachmentsBefore ) - else { throw MessageReceiverError.outdatedMessage } + else { throw MessageError.outdatedMessage } return } @@ -526,7 +523,7 @@ public enum MessageReceiver { ) switch (conversationInConfig, canPerformConfigChange) { - case (false, false): throw MessageReceiverError.outdatedMessage + case (false, false): throw MessageError.outdatedMessage default: break } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index a036aa229f..ca7e9378a6 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -18,7 +18,9 @@ extension MessageSender { using dependencies: Dependencies ) throws { // Only 'VisibleMessage' types can be sent via this method - guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } + guard interaction.variant == .standardOutgoing else { + throw MessageError.invalidMessage("Message was not an outgoing message") + } guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } send( @@ -319,13 +321,13 @@ extension MessageSender { threadId: String, message: Message, destination: Message.Destination?, - error: MessageSenderError, + error: MessageError, interactionId: Int64?, using dependencies: Dependencies ) -> Error { - // Log a message for any 'other' errors + // Log a message for any 'sendFailure' errors switch error { - case .other(let cat, let description, let error): + case .sendFailure(let cat, let description, let error): Log.error([.messageSender, cat].compactMap { $0 }, "\(description) due to error: \(error).") default: break } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index eb83cf6f86..fd97b65854 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -19,7 +19,7 @@ public final class MessageSender { public enum Event { case willSend(Message, Message.Destination, interactionId: Int64?) case success(Message, Message.Destination, interactionId: Int64?, serverTimestampMs: Int64?, serverExpirationMs: Int64?) - case failure(Message, Message.Destination, interactionId: Int64?, error: MessageSenderError) + case failure(Message, Message.Destination, interactionId: Int64?, error: MessageError) var message: Message { switch self { @@ -122,14 +122,14 @@ public final class MessageSender { message, destination, interactionId: interactionId, - error: .other(nil, "Couldn't send message", error) + error: .sendFailure(nil, "Couldn't send message", error) )) } } ) .map { _, response in response.message } } - catch let error as MessageSenderError { + catch let error as MessageError { onEvent?(.failure(message, destination, interactionId: interactionId, error: error)) throw error } @@ -147,7 +147,7 @@ public final class MessageSender { using dependencies: Dependencies ) throws -> Network.PreparedRequest { guard let namespace: Network.SnodeAPI.Namespace = namespace else { - throw MessageSenderError.invalidMessage + throw MessageError.missingRequiredField("namespace") } /// Set the sender/recipient info (needed to be valid) @@ -241,7 +241,7 @@ public final class MessageSender { let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw MessageSenderError.invalidMessage } + else { throw MessageError.invalidMessage("Configuration doesn't meet requirements to send to a community") } // Set the sender/recipient info (needed to be valid) let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -257,7 +257,7 @@ public final class MessageSender { ed25519SecretKey: userEdKeyPair.secretKey ) ) - else { throw MessageSenderError.signingFailed } + else { throw MessageError.requiredSignatureMissing } return SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString }() @@ -277,7 +277,7 @@ public final class MessageSender { ) } - guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername } + guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageError.invalidSender } let plaintext: Data = try MessageSender.encodeMessageForSending( namespace: .default, @@ -324,7 +324,7 @@ public final class MessageSender { guard (attachments ?? []).isEmpty, case .communityInbox(_, _, let recipientBlindedPublicKey) = destination - else { throw MessageSenderError.invalidMessage } + else { throw MessageError.invalidMessage("Configuration doesn't meet requirements to send to community inbox") } let userSessionId: SessionId = dependencies[cache: .general].sessionId message.sender = userSessionId.hexString @@ -384,14 +384,15 @@ public final class MessageSender { using dependencies: Dependencies ) throws -> Data { /// Check the message itself is valid - guard - message.isValid(isSending: true), - let sentTimestampMs: UInt64 = message.sentTimestampMs - else { throw MessageSenderError.invalidMessage } + try message.validateMessage(isSending: true) + + guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { + throw MessageError.missingRequiredField("sentTimestampMs") + } /// Messages sent to `revokedRetrievableGroupMessages` should be sent directly instead of via the `MessageSender` guard namespace != .revokedRetrievableGroupMessages else { - throw MessageSenderError.invalidDestination + throw MessageError.invalidMessage("Attempted to send to namespace \(namespace) via the wrong pipeline") } /// Add attachments if needed and convert to serialised proto data @@ -399,7 +400,7 @@ public final class MessageSender { let plaintext: Data = try? message.toProto()? .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment })? .serializedData() - else { throw MessageSenderError.protoConversionFailed } + else { throw MessageError.protoConversionFailed } return try dependencies[singleton: .crypto].tryGenerate( .encodedMessage( diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 8e65f4550d..2d0fe7c8cf 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -69,23 +69,23 @@ public extension NotificationsManagerType { shouldShowForMessageRequest: () -> Bool, using dependencies: Dependencies ) throws { - guard let sender: String = message.sender else { throw MessageReceiverError.invalidSender } + guard let sender: String = message.sender else { throw MessageError.invalidSender } /// Don't show notifications for the `Note to Self` thread or messages sent from the current user guard !currentUserSessionIds.contains(threadId) && !currentUserSessionIds.contains(sender) else { - throw MessageReceiverError.selfSend + throw MessageError.selfSend } /// Ensure that the thread isn't muted guard dependencies.dateNow.timeIntervalSince1970 > (notificationSettings.mutedUntil ?? 0) else { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } switch message { /// For a `VisibleMessage` we should only notify if the notification mode is `all` or if `mentionsOnly` and the /// user was actually mentioned case let visibleMessage as VisibleMessage: - guard interactionVariant == .standardIncoming else { throw MessageReceiverError.ignorableMessage } + guard interactionVariant == .standardIncoming else { throw MessageError.ignorableMessage } guard !notificationSettings.mentionsOnly || Interaction.isUserMentioned( @@ -93,7 +93,7 @@ public extension NotificationsManagerType { body: visibleMessage.text, quoteAuthorId: visibleMessage.quote?.authorId ) - else { throw MessageReceiverError.ignorableMessage } + else { throw MessageError.ignorableMessage } /// If the message is a reaction then we only want to show notifications for `contact` conversations, any only if the /// reaction isn't added to a message sent by the reactor @@ -101,30 +101,32 @@ public extension NotificationsManagerType { switch threadVariant { case .contact: guard visibleMessage.reaction?.publicKey != sender else { - throw MessageReceiverError.ignorableMessage + throw MessageError.ignorableMessage } break - case .legacyGroup, .group, .community: throw MessageReceiverError.ignorableMessage + case .legacyGroup, .group, .community: throw MessageError.ignorableMessage } } break /// Calls are only supported in `contact` conversations and we only want to notify for missed calls case let callMessage as CallMessage: - guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } - guard case .preOffer = callMessage.kind else { throw MessageReceiverError.ignorableMessage } + guard threadVariant == .contact else { + throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") + } + guard case .preOffer = callMessage.kind else { throw MessageError.ignorableMessage } switch callMessage.state { case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: throw MessageReceiverError.ignorableMessage + default: throw MessageError.ignorableMessage } /// Group invitations and promotions may show notifications in some cases case is GroupUpdateInviteMessage, is GroupUpdatePromoteMessage: break /// No other messages should have notifications - default: throw MessageReceiverError.ignorableMessage + default: throw MessageError.ignorableMessage } /// Ensure the sender isn't blocked (this should be checked when parsing the message but we should also check here in case @@ -133,7 +135,7 @@ public extension NotificationsManagerType { dependencies.mutate(cache: .libSession, { cache in !cache.isContactBlocked(contactId: sender) }) - else { throw MessageReceiverError.senderBlocked } + else { throw MessageError.senderBlocked } /// Ensure the message hasn't already been maked as read (don't want to show notification in that case) guard @@ -145,14 +147,14 @@ public extension NotificationsManagerType { openGroupUrlInfo: openGroupUrlInfo ) }) - else { throw MessageReceiverError.ignorableMessage } + else { throw MessageError.ignorableMessage } /// If the thread is a message request then we only want to show a notification for the first message switch (threadVariant, isMessageRequest) { case (.community, _), (.legacyGroup, _), (.contact, false), (.group, false): break case (.contact, true), (.group, true): guard shouldShowForMessageRequest() else { - throw MessageReceiverError.ignorableMessageRequestMessage + throw MessageError.ignorableMessageRequestMessage } break } @@ -200,7 +202,7 @@ public extension NotificationsManagerType { .put(key: "conversation_name", value: groupName) .localized() - case (_, _, _, .legacyGroup): throw MessageReceiverError.ignorableMessage + case (_, _, _, .legacyGroup): throw MessageError.ignorableMessage } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index ab0851838b..caa07b0a42 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -308,7 +308,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { } catch { /// For some error cases we want to update the last hash so do so - if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { + if (error as? MessageError)?.shouldUpdateLastHash == true { hadValidHashUpdate = (message.info?.storeUpdatedLastHash(db) == true) } @@ -317,8 +317,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { /// will be a lot since we each service node duplicates messages) case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.selfSend: + MessageError.duplicateMessage, + MessageError.selfSend: break case DatabaseError.SQLITE_ABORT: diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 4693cffae8..4c2d2897a3 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -925,7 +925,7 @@ public class ExtensionHelper: ExtensionHelperType { if result.validMessageCount != result.rawMessageCount { failureStandardCount += (result.rawMessageCount - result.validMessageCount) - Log.error(.cat, "Discarding some standard messages due to error: \(MessageReceiverError.failedToProcess)") + Log.error(.cat, "Discarding \((result.rawMessageCount - result.validMessageCount)) standard message(s) as they could not be processed.") } } diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 7de5f146de..c6274f4984 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -94,7 +94,7 @@ class CryptoSMKSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } } @@ -132,7 +132,7 @@ class CryptoSMKSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws an error if the ciphertext is too short @@ -144,7 +144,7 @@ class CryptoSMKSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } } } diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift index 22fb802b43..4849b49d8d 100644 --- a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -367,7 +367,7 @@ class MessageDeduplicationSpec: AsyncSpec { ignoreDedupeFiles: false, using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } } @@ -403,7 +403,7 @@ class MessageDeduplicationSpec: AsyncSpec { ignoreDedupeFiles: false, using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } } @@ -1068,7 +1068,7 @@ class MessageDeduplicationSpec: AsyncSpec { uniqueIdentifier: "testId", using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) } // MARK: ---- throws when the message is a legacy duplicate @@ -1097,7 +1097,7 @@ class MessageDeduplicationSpec: AsyncSpec { legacyIdentifier: "testLegacyId", using: dependencies ) - }.to(throwError(MessageReceiverError.duplicateMessage)) + }.to(throwError(MessageError.duplicateMessage)) expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") }) @@ -1153,7 +1153,7 @@ class MessageDeduplicationSpec: AsyncSpec { ), using: dependencies ) - }.to(throwError(MessageReceiverError.duplicatedCall)) + }.to(throwError(MessageError.duplicatedCall)) } } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index cb8ff17dd8..cf6b601349 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -339,7 +339,7 @@ class LibSessionSpec: QuickSpec { catch { resultError = error } } - expect(resultError).to(matchError(MessageSenderError.noKeyPair)) + expect(resultError).to(matchError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws when it fails to generate a new identity ed25519 keyPair @@ -363,7 +363,7 @@ class LibSessionSpec: QuickSpec { catch { resultError = error } } - expect(resultError).to(matchError(MessageSenderError.noKeyPair)) + expect(resultError).to(matchError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws when given an invalid member id diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift index 7b04fa35a1..76024092d5 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift @@ -79,7 +79,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.encryptionFailed)) + .to(throwError(MessageError.encodingFailed)) } // MARK: ---- throws an error if there is no ed25519 keyPair @@ -95,7 +95,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } } @@ -155,7 +155,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + .to(throwError(CryptoError.missingUserSecretKey)) } // MARK: ---- throws an error if the data is too short @@ -170,7 +170,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if the data version is not 0 @@ -189,7 +189,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if it cannot decrypt the data @@ -204,7 +204,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } // MARK: ---- throws an error if the inner bytes are too short @@ -223,7 +223,7 @@ class CryptoOpenGroupSpec: QuickSpec { ) ) } - .to(throwError(MessageReceiverError.decryptionFailed)) + .to(throwError(MessageError.decodingFailed)) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index b1107b2605..921a2fc334 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -1101,7 +1101,7 @@ class MessageReceiverGroupsSpec: QuickSpec { }) } - expect(result.failure).to(matchError(MessageReceiverError.invalidMessage)) + expect(result.failure).to(matchError(MessageError.invalidMessage)) } // MARK: ---- updates the GROUP_KEYS state correctly @@ -1195,7 +1195,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1214,7 +1214,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1235,7 +1235,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1380,7 +1380,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1399,7 +1399,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1420,7 +1420,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1841,7 +1841,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -1860,7 +1860,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -2113,7 +2113,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -2132,7 +2132,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -2451,7 +2451,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -2470,7 +2470,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -2491,7 +2491,7 @@ class MessageReceiverGroupsSpec: QuickSpec { suppressNotifications: false, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } } @@ -3311,7 +3311,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.invalidMessage)) } } @@ -3331,7 +3331,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.invalidMessage)) } } @@ -3351,7 +3351,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageReceiverError.invalidMessage)) + .to(throwError(MessageError.invalidMessage)) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 8681ce24c8..9ab77d2e63 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -73,7 +73,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidSender)) + }.to(throwError(MessageError.invalidSender)) } // MARK: -- throws if the message was sent to note to self @@ -91,7 +91,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.selfSend)) + }.to(throwError(MessageError.selfSend)) } // MARK: -- throws if the message was sent by the current user @@ -115,7 +115,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.selfSend)) + }.to(throwError(MessageError.selfSend)) } // MARK: -- throws if notifications are muted @@ -138,7 +138,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- throws if the message is not an incoming message @@ -156,7 +156,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- for mentions only @@ -185,7 +185,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: ---- does not throw if the current user is mentioned @@ -297,7 +297,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -316,7 +316,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -335,7 +335,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -381,7 +381,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -395,7 +395,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -409,7 +409,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage)) } // MARK: ---- throws if the message is not a preOffer @@ -435,7 +435,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: ---- throws for the expected states @@ -446,7 +446,7 @@ class NotificationsManagerSpec: QuickSpec { let stateToError: [String: String] = CallMessage.MessageInfo.State.allCases .filter { !nonThrowingStates.contains($0) } .reduce(into: [:]) { result, next in - result["\(next)"] = "\(MessageReceiverError.ignorableMessage)" + result["\(next)"] = "\(MessageError.ignorableMessage)" } var result: [String: String] = [:] @@ -570,7 +570,7 @@ class NotificationsManagerSpec: QuickSpec { expect(Message.Variant.allCases.count - nonThrowingMessageTypes.count).to(equal(throwingMessages.count)) let messageTypeNameToError: [String: String] = throwingMessages .reduce(into: [:]) { result, next in - result["\(type(of: next))"] = "\(MessageReceiverError.ignorableMessage)" + result["\(type(of: next))"] = "\(MessageError.ignorableMessage)" } var result: [String: String] = [:] @@ -613,7 +613,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.senderBlocked)) + }.to(throwError(MessageError.senderBlocked)) } // MARK: -- throws if the message was already read @@ -641,7 +641,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } // MARK: -- throws if the message was sent to a message request and we should not show @@ -659,7 +659,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { false }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage)) + }.to(throwError(MessageError.ignorableMessageRequestMessage)) } // MARK: -- does not throw if the message was sent to a message request and we should show @@ -896,7 +896,7 @@ class NotificationsManagerSpec: QuickSpec { groupNameRetriever: { _, _ in nil }, using: dependencies ) - }.to(throwError(MessageReceiverError.ignorableMessage)) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -1313,7 +1313,7 @@ class NotificationsManagerSpec: QuickSpec { return false } ) - }.to(throwError(MessageReceiverError.ignorableMessageRequestMessage)) + }.to(throwError(MessageError.ignorableMessageRequestMessage)) expect(didCallShouldShowForMessageRequest).to(beTrue()) } diff --git a/SessionNotificationServiceExtension/NotificationResolution.swift b/SessionNotificationServiceExtension/NotificationResolution.swift index c291628c84..7cb42c61fa 100644 --- a/SessionNotificationServiceExtension/NotificationResolution.swift +++ b/SessionNotificationServiceExtension/NotificationResolution.swift @@ -22,6 +22,7 @@ enum NotificationResolution: CustomStringConvertible { case ignoreDueToDuplicateMessage case ignoreDueToDuplicateCall case ignoreDueToContentSize(Network.PushNotification.NotificationMetadata) + case ignoreDueToCryptoError(CryptoError) case errorTimeout case errorNotReadyForExtensions @@ -29,7 +30,7 @@ enum NotificationResolution: CustomStringConvertible { case errorCallFailure case errorNoContent(Network.PushNotification.NotificationMetadata) case errorProcessing(Network.PushNotification.ProcessResult) - case errorMessageHandling(MessageReceiverError, Network.PushNotification.NotificationMetadata) + case errorMessageHandling(MessageError, Network.PushNotification.NotificationMetadata) case errorOther(Error) public var description: String { @@ -55,6 +56,9 @@ enum NotificationResolution: CustomStringConvertible { case .ignoreDueToContentSize(let metadata): return "Ignored: Notification content from \(metadata.messageOriginString) was too long (\(Format.fileSize(UInt(metadata.dataLength))))" + + case .ignoreDueToCryptoError(let error): + return "Ignored: Crypto error occurred: \(error)" case .errorTimeout: return "Failed: Execution time expired" case .errorNotReadyForExtensions: return "Failed: App not ready for extensions" @@ -77,7 +81,7 @@ enum NotificationResolution: CustomStringConvertible { .ignoreDueToSelfSend, .ignoreDueToNonLegacyGroupLegacyNotification, .ignoreDueToOutdatedMessage, .ignoreDueToRequiresNoNotification, .ignoreDueToMessageRequest, .ignoreDueToDuplicateMessage, .ignoreDueToDuplicateCall, - .ignoreDueToContentSize: + .ignoreDueToContentSize, .ignoreDueToCryptoError: return .info case .errorNotReadyForExtensions, .errorLegacyPushNotification, .errorNoContent, .errorCallFailure: diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 0e3e03e4b9..e0e4db3f1e 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -58,7 +58,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Setup the extension and handle the notification var notificationInfo: NotificationInfo = self.cachedNotificationInfo.with(content: content) - var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, .invalid, "", nil, nil) + var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, nil, "", nil, nil) do { let mainAppUnreadCount: Int = try performSetup(notificationInfo) @@ -216,7 +216,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension var threadDisplayName: String? switch processedMessage { - case .invalid: throw MessageReceiverError.invalidMessage case .config: threadVariant = nil threadDisplayName = nil @@ -254,7 +253,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private func handleNotification(_ notification: ProcessedNotification) throws { switch notification.processedMessage { - case .invalid: throw MessageReceiverError.invalidMessage + case .none: throw MessageError.missingRequiredField("processedMessage") case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data, _): try handleConfigMessage( notification, @@ -284,6 +283,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension serverTimestampMs: Int64, data: Data ) throws { + guard let processedMessage: ProcessedMessage = notification.processedMessage else { + throw MessageError.missingRequiredField("processedMessage") + } try dependencies.mutate(cache: .libSession) { cache in try cache.mergeConfigMessages( swarmPublicKey: swarmPublicKey, @@ -332,7 +334,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Since we successfully handled the message we should now create the dedupe file for the message so we don't /// show duplicate PNs - try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + try MessageDeduplication.createDedupeFile(processedMessage, using: dependencies) /// No notification should be shown for config messages so we can just succeed silently here completeSilenty(notification.info, .success(notification.info.metadata)) @@ -443,7 +445,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: Array(promoteMessage.groupIdentitySeed)) ) - else { throw MessageReceiverError.invalidMessage } + else { throw CryptoError.invalidSeed } try handleGroupInviteOrPromotion( notification, @@ -549,13 +551,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (true, true, _): guard let sender: String = callMessage.sender else { - throw MessageReceiverError.invalidMessage + throw MessageError.missingRequiredField("sender") } guard let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ) - else { throw SnodeAPIError.noKeyPair } + else { throw CryptoError.invalidSeed } Log.info(.calls, "Sending end call message because there is an ongoing call.") /// Update the `CallMessage.state` value so the correct notification logic can occur @@ -600,12 +602,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension libSession.isMessageRequest(threadId: threadId, threadVariant: threadVariant) } + guard let sender: String = callMessage.sender, !sender.isEmpty else { + throw MessageError.missingRequiredField("sender") + } + guard let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, sentTimestampMs > 0 else { + throw MessageError.missingRequiredField("sentTimestampMs") + } guard - let sender: String = callMessage.sender, - let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, threadVariant == .contact, !isMessageRequest - else { throw MessageReceiverError.invalidMessage } + else { throw MessageError.invalidMessage("Calls are only supported in 1-to-1 conversations") } /// Save the message and generate any deduplication files needed try saveMessage( @@ -834,11 +840,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set ) throws { + guard let processedMessage: ProcessedMessage = notification.processedMessage else { + throw MessageError.missingRequiredField("processedMessage") + } + /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait /// for a poll to return do { - guard let sentTimestamp: Int64 = messageInfo.message.sentTimestampMs.map(Int64.init) else { - throw MessageReceiverError.invalidMessage + guard let sentTimestamp: UInt64 = messageInfo.message.sentTimestampMs, sentTimestamp > 0 else { + throw MessageError.missingRequiredField("sentTimestamp") } try dependencies[singleton: .extensionHelper].saveMessage( @@ -903,7 +913,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// Since we successfully handled the message we should now create the dedupe file for the message so we don't /// show duplicate PNs - try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + try MessageDeduplication.createDedupeFile(processedMessage, using: dependencies) try MessageDeduplication.createCallDedupeFilesIfNeeded( threadId: threadId, callMessage: messageInfo.message as? CallMessage, @@ -1013,44 +1023,44 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (NotificationError.processingError(let result, let errorMetadata), _, _): self.completeSilenty(info.with(metadata: errorMetadata), .errorProcessing(result)) + + case (CryptoError.invalidSeed, _, _): + self.completeSilenty(info, .ignoreDueToCryptoError(.invalidSeed)) - case (MessageReceiverError.selfSend, _, _): + case (MessageError.selfSend, _, _): self.completeSilenty(info, .ignoreDueToSelfSend) - case (MessageReceiverError.noGroupKeyPair, _, _): - self.completeSilenty(info, .errorLegacyPushNotification) - - case (MessageReceiverError.outdatedMessage, _, _): + case (MessageError.outdatedMessage, _, _): self.completeSilenty(info, .ignoreDueToOutdatedMessage) - case (MessageReceiverError.ignorableMessage, _, _): + case (MessageError.ignorableMessage, _, _): self.completeSilenty(info, .ignoreDueToRequiresNoNotification) - case (MessageReceiverError.ignorableMessageRequestMessage, _, _): + case (MessageError.ignorableMessageRequestMessage, _, _): self.completeSilenty(info, .ignoreDueToMessageRequest) - case (MessageReceiverError.duplicateMessage, _, _): + case (MessageError.duplicateMessage, _, _): self.completeSilenty(info, .ignoreDueToDuplicateMessage) - case (MessageReceiverError.duplicatedCall, _, _): + case (MessageError.duplicatedCall, _, _): self.completeSilenty(info, .ignoreDueToDuplicateCall) - /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't + /// If it was a `decodingFailed` error, but it was for a config namespace then just fail silently (don't /// want to show the fallback notification in this case) - case (MessageReceiverError.decryptionFailed, _, true): - self.completeSilenty(info, .errorMessageHandling(.decryptionFailed, info.metadata)) + case (MessageError.decodingFailed, _, true): + self.completeSilenty(info, .errorMessageHandling(.decodingFailed, info.metadata)) - /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or + /// If it was a `decodingFailed` error for a group conversation and the group doesn't exist or /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want /// to show the fallback notification in these cases) - case (MessageReceiverError.decryptionFailed, .group, _): + case (MessageError.decodingFailed, .group, _): guard let threadId: String = processedNotification?.threadId, dependencies.mutate(cache: .libSession, { cache in cache.hasCredentials(groupSessionId: SessionId(.group, hex: threadId)) }) else { - self.completeSilenty(info, .errorMessageHandling(.decryptionFailed, info.metadata)) + self.completeSilenty(info, .errorMessageHandling(.decodingFailed, info.metadata)) return } @@ -1060,10 +1070,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension threadId: processedNotification?.threadId, threadVariant: processedNotification?.threadVariant, threadDisplayName: processedNotification?.threadDisplayName, - resolution: .errorMessageHandling(.decryptionFailed, info.metadata) + resolution: .errorMessageHandling(.decodingFailed, info.metadata) ) - case (let msgError as MessageReceiverError, _, _): + case (let msgError as MessageError, _, _): self.handleFailure( info, threadId: processedNotification?.threadId, @@ -1308,7 +1318,7 @@ private extension NotificationServiceExtension { typealias ProcessedNotification = ( info: NotificationInfo, - processedMessage: ProcessedMessage, + processedMessage: ProcessedMessage?, threadId: String, threadVariant: SessionThread.Variant?, threadDisplayName: String? diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 60295606b9..3678c216fd 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -282,7 +282,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { - throw MessageSenderError.noThread + throw MessageError.messageRequiresThreadToExistButThreadDoesNotExist } // Update the thread to be visible (if it isn't already) diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index d4cba38c81..cbda034439 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -2,7 +2,7 @@ import Foundation -public enum CryptoError: Error { +public enum CryptoError: Error, CustomStringConvertible { case invalidSeed case keyGenerationFailed case randomGenerationFailed @@ -15,4 +15,21 @@ public enum CryptoError: Error { case invalidAuthentication case invalidBase64EncodedData case invalidKey + + public var description: String { + switch self { + case .invalidSeed: return "Invalid seed." + case .keyGenerationFailed: return "Key generation failed." + case .randomGenerationFailed: return "Random generation failed." + case .signatureGenerationFailed: return "Signature generation failed." + case .signatureVerificationFailed: return "Signature verification failed." + case .encryptionFailed: return "Encryption failed." + case .decryptionFailed: return "Decryption failed." + case .failedToGenerateOutput: return "Failed to generate output." + case .missingUserSecretKey: return "Missing user secret key." + case .invalidAuthentication: return "Invalid authentication." + case .invalidBase64EncodedData: return "Invalid base64 encoded data." + case .invalidKey: return "Invalid key." + } + } } From 60f4fefea0863fce5318135937639256b6d6c9b5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 15:59:03 +1100 Subject: [PATCH 06/66] Cleaned up libSession message decoding logic --- .../Crypto/Crypto+LibSession.swift | 204 +++++++++--------- 1 file changed, 100 insertions(+), 104 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 76b65c8081..481167a732 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -133,115 +133,111 @@ public extension Crypto.Generator { let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var error: [CChar] = [CChar](repeating: 0, count: 256) - /// Communities have a separate decoding function to handle them first - if case .community(_, let sender, let posted, _, _, _, _) = origin { - var result: session_protocol_decoded_community_message = session_protocol_decode_for_community( - cEncodedMessage, - cEncodedMessage.count, - currentTimestampMs, - nil, - 0, - &error, - error.count - ) - defer { session_protocol_decode_for_community_free(&result) } - - guard result.success else { - Log.error(.messageSender, "Failed to decrypt community message due to error: \(String(cString: error))") - throw MessageReceiverError.decryptionFailed - } - - let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext_unpadded_size)) - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) - - return (proto, sender, sentTimestampMs) - } - - /// Community inbox messages aren't currently handled via the new decode function so we need to custom handle them - // FIXME: Fold into `session_protocol_decode_envelope` once support is added - if case .communityInbox(let posted, _, let serverPublicKey, let senderId, let recipientId) = origin { - let (plaintextWithPadding, sender): (Data, String) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionBlindingProtocol( - ciphertext: encodedMessage, - senderId: senderId, - recipientId: recipientId, - serverPublicKey: serverPublicKey + switch origin { + case .community(_, let sender, let posted, _, _, _, _): + var result: session_protocol_decoded_community_message = session_protocol_decode_for_community( + cEncodedMessage, + cEncodedMessage.count, + currentTimestampMs, + nil, + 0, + &error, + error.count ) - ) - - let plaintext = plaintextWithPadding.removePadding() - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) - - return (proto, sender, sentTimestampMs) - } - - guard case .swarm(let publicKey, let namespace, let serverHash, let serverTimestampMs, let serverExpirationTimestamp) = origin else { - throw MessageReceiverError.invalidMessage - } - - /// Function to provide pointers to the keys based on the namespace the message was received from - func withKeys( - for namespace: Network.SnodeAPI.Namespace, - using dependencies: Dependencies, - _ closure: (UnsafePointer?, Int) throws -> R - ) throws -> R { - let privateKeys: [[UInt8]] - - switch namespace { - case .default: - let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + defer { session_protocol_decode_for_community_free(&result) } + + guard result.success else { + Log.error(.messageSender, "Failed to decode community message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext_unpadded_size)) + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + + return (proto, sender, sentTimestampMs) + + case .communityInbox(let posted, _, let serverPublicKey, let senderId, let recipientId): + // FIXME: Fold into `session_protocol_decode_envelope` once support is added + let (plaintextWithPadding, sender): (Data, String) = try dependencies[singleton: .crypto].tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: encodedMessage, + senderId: senderId, + recipientId: recipientId, + serverPublicKey: serverPublicKey + ) + ) + + let plaintext = plaintextWithPadding.removePadding() + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) + + return (proto, sender, sentTimestampMs) + + case .swarm(_, let namespace, _, _, _): + /// Function to provide pointers to the keys based on the namespace the message was received from + func withKeys( + for namespace: Network.SnodeAPI.Namespace, + using dependencies: Dependencies, + _ closure: (UnsafePointer?, Int) throws -> R + ) throws -> R { + let privateKeys: [[UInt8]] - guard !ed25519SecretKey.isEmpty else { throw MessageReceiverError.noUserED25519KeyPair } + switch namespace { + case .default: + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + + guard !ed25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } + + privateKeys = [ed25519SecretKey] + + case .groupMessages: + throw MessageError.invalidMessage("TODO: Add support") + + default: + throw MessageError.invalidMessage("Tried to decode a message from an incorrect namespace: \(namespace)") + } - privateKeys = [ed25519SecretKey] + return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in + try closure(cPrivateKeys, cPrivateKeysLen) + } + } + + return try withKeys(for: namespace, using: dependencies) { cPrivateKeys, cPrivateKeysLen in + let cEncodedMessage: [UInt8] = Array(encodedMessage) + var cKeys: session_protocol_decode_envelope_keys = session_protocol_decode_envelope_keys() + cKeys.set(\.ed25519_privkeys, to: cPrivateKeys) + cKeys.set(\.ed25519_privkeys_len, to: cPrivateKeysLen) - case .groupMessages: - throw MessageReceiverError.invalidMessage + var result: session_protocol_decoded_envelope = session_protocol_decode_envelope( + &cKeys, + cEncodedMessage, + cEncodedMessage.count, + currentTimestampMs, + nil, + 0, + &error, + error.count + ) + defer { session_protocol_decode_envelope_free(&result) } - default: throw MessageReceiverError.invalidMessage - } - - return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in - try closure(cPrivateKeys, cPrivateKeysLen) - } - } - - return try withKeys(for: namespace, using: dependencies) { cPrivateKeys, cPrivateKeysLen in - let cEncodedMessage: [UInt8] = Array(encodedMessage) - var cKeys: session_protocol_decode_envelope_keys = session_protocol_decode_envelope_keys() - cKeys.set(\.ed25519_privkeys, to: cPrivateKeys) - cKeys.set(\.ed25519_privkeys_len, to: cPrivateKeysLen) - - var result: session_protocol_decoded_envelope = session_protocol_decode_envelope( - &cKeys, - cEncodedMessage, - cEncodedMessage.count, - currentTimestampMs, - nil, - 0, - &error, - error.count - ) - defer { session_protocol_decode_envelope_free(&result) } - - guard result.success else { - Log.error(.messageSender, "Failed to decrypt message due to error: \(String(cString: error))") - throw MessageReceiverError.decryptionFailed - } - - let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext.size)) - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sender: SessionId = SessionId(.standard, publicKey: result.get(\.sender_x25519_pubkey)) - - return (proto, sender.hexString, result.envelope.timestamp_ms) + guard result.success else { + Log.error(.messageReceiver, "Failed to decode message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext.size)) + let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + let sender: SessionId = SessionId(.standard, publicKey: result.get(\.sender_x25519_pubkey)) + + return (proto, sender.hexString, result.envelope.timestamp_ms) + } } } } From 0aa42337254cea89b7a052ac24d3a3cffdb83679 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 17 Oct 2025 08:45:38 +1100 Subject: [PATCH 07/66] Added support for group decoding, updated with latest naming changes --- .../Crypto/Crypto+LibSession.swift | 32 ++++++++++++----- .../LibSession+GroupKeys.swift | 36 +++++++++++++++++-- .../LibSession+SessionMessagingKit.swift | 2 ++ .../Sending & Receiving/MessageReceiver.swift | 1 - .../Pollers/GroupPoller.swift | 2 +- .../Utilities/TypeConversion+Utilities.swift | 13 ++++++- 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 481167a732..3ac7f13f49 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -178,14 +178,16 @@ public extension Crypto.Generator { return (proto, sender, sentTimestampMs) - case .swarm(_, let namespace, _, _, _): + case .swarm(let publicKey, let namespace, _, _, _): /// Function to provide pointers to the keys based on the namespace the message was received from func withKeys( for namespace: Network.SnodeAPI.Namespace, + publicKey: String, using dependencies: Dependencies, - _ closure: (UnsafePointer?, Int) throws -> R + _ closure: (span_u8, UnsafePointer?, Int) throws -> R ) throws -> R { let privateKeys: [[UInt8]] + let sessionId: SessionId = try SessionId(from: publicKey) switch namespace { case .default: @@ -196,22 +198,36 @@ public extension Crypto.Generator { privateKeys = [ed25519SecretKey] case .groupMessages: - throw MessageError.invalidMessage("TODO: Add support") + guard sessionId.prefix == .group else { + throw MessageError.requiresGroupId(publicKey) + } + + privateKeys = try dependencies.mutate(cache: .libSession) { cache in + try cache.allActiveGroupKeys(groupSessionId: sessionId) + } default: throw MessageError.invalidMessage("Tried to decode a message from an incorrect namespace: \(namespace)") } - return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in - try closure(cPrivateKeys, cPrivateKeysLen) + /// Exclude the prefix when providing the publicKey + return try sessionId.publicKey.withUnsafeSpan { cPublicKey in + return try privateKeys.withUnsafeSpanOfSpans { cPrivateKeys, cPrivateKeysLen in + try closure(cPublicKey, cPrivateKeys, cPrivateKeysLen) + } } } - return try withKeys(for: namespace, using: dependencies) { cPrivateKeys, cPrivateKeysLen in + return try withKeys(for: namespace, publicKey: publicKey, using: dependencies) { cPublicKey, cPrivateKeys, cPrivateKeysLen in let cEncodedMessage: [UInt8] = Array(encodedMessage) var cKeys: session_protocol_decode_envelope_keys = session_protocol_decode_envelope_keys() - cKeys.set(\.ed25519_privkeys, to: cPrivateKeys) - cKeys.set(\.ed25519_privkeys_len, to: cPrivateKeysLen) + cKeys.set(\.decrypt_keys, to: cPrivateKeys) + cKeys.set(\.decrypt_keys_len, to: cPrivateKeysLen) + + /// If it's a group message then we need to set the group pubkey + if namespace == .groupMessages { + cKeys.set(\.group_ed25519_pubkey, to: cPublicKey) + } var result: session_protocol_decoded_envelope = session_protocol_decode_envelope( &cKeys, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift index 4320c770e5..ec7e6a9bd6 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift @@ -219,6 +219,40 @@ public extension LibSession.Cache { return Array(UnsafeBufferPointer(start: result.data, count: result.size)) } + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { + guard let config: LibSession.Config = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: nil) + } + guard case .groupKeys(let conf, _, _) = config else { + throw LibSessionError.invalidConfigObject(wanted: .groupKeys, got: config) + } + + /// Get the number of active keys first, if there aren't any then no need to allocate anything + let activeKeys: Int = groups_keys_size(conf) + + guard activeKeys > 0 else { return [] } + + let destBuffer = UnsafeMutableBufferPointer.allocate(capacity: activeKeys) + defer { destBuffer.deallocate() } + + destBuffer.initialize(repeating: span_u8()) + + let numKeys: Int = groups_keys_get_keys(conf, 0, destBuffer.baseAddress, activeKeys) + var keys: [[UInt8]] = [] + keys.reserveCapacity(numKeys) + + for i in 0.. Bool { guard case .groupKeys(let conf, _, _) = config(for: .groupKeys, sessionId: groupSessionId) else { return false @@ -227,5 +261,3 @@ public extension LibSession.Cache { return groups_keys_is_admin(conf) } } - -extension span_u8: @retroactive CAccessible {} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index eb65a9ab0d..493ba9efd2 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1101,6 +1101,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func hasCredentials(groupSessionId: SessionId) -> Bool func secretKey(groupSessionId: SessionId) -> [UInt8]? func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] func isAdmin(groupSessionId: SessionId) -> Bool func loadAdminKey( groupIdentitySeed: Data, @@ -1382,6 +1383,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func hasCredentials(groupSessionId: SessionId) -> Bool { return false } func secretKey(groupSessionId: SessionId) -> [UInt8]? { return nil } func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { throw CryptoError.invalidKey } + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { throw CryptoError.invalidKey } func isAdmin(groupSessionId: SessionId) -> Bool { return false } func markAsInvited(groupSessionIds: [String]) throws {} func markAsKicked(groupSessionIds: [String]) throws {} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index a8065a714d..3610800b16 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -244,7 +244,6 @@ public enum MessageReceiver { threadVariant: threadVariant, message: message, serverExpirationTimestamp: serverExpirationTimestamp, - associatedWithProto: proto, suppressNotifications: suppressNotifications, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 7e816d4a4f..85c55acb6f 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -187,7 +187,7 @@ public extension GroupPoller { @discardableResult public func getOrCreatePoller(for swarmPublicKey: String) -> SwarmPollerType { guard let poller: GroupPoller = _pollers[swarmPublicKey.lowercased()] else { let poller: GroupPoller = GroupPoller( - pollerName: "Closed group poller with public key: \(swarmPublicKey)", // stringlint:ignore + pollerName: "Group poller with public key: \(swarmPublicKey)", // stringlint:ignore pollerQueue: Threading.groupPollerQueue, pollerDestination: .swarm(swarmPublicKey), pollerDrainBehaviour: .alwaysRandom, diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index 8d3b49f97d..07abc996a0 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -111,7 +111,7 @@ public extension Collection where Element == [UInt8]? { } public extension Collection where Element: DataProtocol { - func withUnsafeSpanOfSpans(_ body: (UnsafePointer?, Int) throws -> Result) throws -> Result { + func withUnsafeSpanOfSpans(_ body: (UnsafePointer?, Int) throws -> Result) rethrows -> Result { var allocatedBuffers: [UnsafeMutableBufferPointer] = [] allocatedBuffers.reserveCapacity(self.count) defer { allocatedBuffers.forEach { $0.deallocate() } } @@ -137,6 +137,17 @@ public extension Collection where Element: DataProtocol { } } +public extension DataProtocol { + func withUnsafeSpan(_ body: (span_u8) throws -> Result) rethrows -> Result { + try Data(self).withUnsafeBytes { bytes in + var span: span_u8 = span_u8() + span.data = UnsafeMutablePointer(mutating: bytes.baseAddress?.assumingMemoryBound(to: UInt8.self)) + span.size = self.count + + return try body(span) + } + } +} // MARK: - CAccessible From c43c27ccfc40c2e428722c07af0bc756dad876aa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Oct 2025 14:07:08 +1100 Subject: [PATCH 08/66] Added wrappers and code to test the add_pro_payment endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added initial `Network.SessionPro` structure • Wrapped a bunch of session pro types • Cleaned up some of the C interop DSL --- Session.xcodeproj/project.pbxproj | 72 ++ .../SessionNetworkEndpoint.swift | 2 + .../AddProPaymentOrGetProProofResponse.swift | 46 ++ .../Requests/AppProPaymentRequest.swift | 42 ++ .../SessionPro/SessionPro.swift | 13 + .../SessionPro/SessionProAPI.swift | 66 ++ .../SessionPro/SessionProEndpoint.swift | 25 + .../SessionPro/Types/PaymentProvider.swift | 29 + .../SessionPro/Types/ProProof.swift | 25 + .../Types/Request+SessionProAPI.swift | 26 + .../SessionPro/Types/ResponseHeader.swift | 22 + .../SessionPro/Types/Signatures.swift | 24 + .../SessionPro/Types/UserTransaction.swift | 37 ++ SessionNetworkingKit/Types/NetworkError.swift | 2 + .../Utilities/TypeConversion+Utilities.swift | 623 ++++++------------ SessionUtilitiesKit/Types/AnyCodable.swift | 62 ++ 16 files changed, 689 insertions(+), 427 deletions(-) create mode 100644 SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift create mode 100644 SessionNetworkingKit/SessionPro/SessionPro.swift create mode 100644 SessionNetworkingKit/SessionPro/SessionProAPI.swift create mode 100644 SessionNetworkingKit/SessionPro/SessionProEndpoint.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/ProProof.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/Signatures.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/UserTransaction.swift create mode 100644 SessionUtilitiesKit/Types/AnyCodable.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a96dd7fa8d..94767370ec 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -458,6 +458,18 @@ FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77AF29B69A65009169BA /* TopBannerController.swift */; }; FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */; }; FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0E353A2AB98773006A81F7 /* AppVersion.swift */; }; + FD0F85612EA82C8B004E0B98 /* SessionPro.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85602EA82C87004E0B98 /* SessionPro.swift */; }; + FD0F85632EA82DF9004E0B98 /* SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */; }; + FD0F85662EA82FCC004E0B98 /* PaymentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */; }; + FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */; }; + FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */; }; + FD0F856D2EA835C5004E0B98 /* Signatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856C2EA835B6004E0B98 /* Signatures.swift */; }; + FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856E2EA83661004E0B98 /* UserTransaction.swift */; }; + FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85722EA83C41004E0B98 /* AnyCodable.swift */; }; + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGetProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */; }; + FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85762EA83D8F004E0B98 /* ProProof.swift */; }; + FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */; }; + FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */; }; FD10AF0C2AF32B9A007709E5 /* SessionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */; }; FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */; }; FD11E22D2CA4D12C001BAF58 /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2286782C38D4FF00BC06F7 /* DifferenceKit */; }; @@ -1851,6 +1863,18 @@ FD0B77AF29B69A65009169BA /* TopBannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBannerController.swift; sourceTree = ""; }; FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtilitiesSpec.swift; sourceTree = ""; }; FD0E353A2AB98773006A81F7 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; + FD0F85602EA82C87004E0B98 /* SessionPro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPro.swift; sourceTree = ""; }; + FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProAPI.swift; sourceTree = ""; }; + FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProvider.swift; sourceTree = ""; }; + FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProEndpoint.swift; sourceTree = ""; }; + FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppProPaymentRequest.swift; sourceTree = ""; }; + FD0F856C2EA835B6004E0B98 /* Signatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signatures.swift; sourceTree = ""; }; + FD0F856E2EA83661004E0B98 /* UserTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTransaction.swift; sourceTree = ""; }; + FD0F85722EA83C41004E0B98 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentOrGetProProofResponse.swift; sourceTree = ""; }; + FD0F85762EA83D8F004E0B98 /* ProProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProProof.swift; sourceTree = ""; }; + FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseHeader.swift; sourceTree = ""; }; + FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+SessionProAPI.swift"; sourceTree = ""; }; FD10AF0B2AF32B9A007709E5 /* SessionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListViewModel.swift; sourceTree = ""; }; FD10AF112AF85D11007709E5 /* Feature+ServiceNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feature+ServiceNetwork.swift"; sourceTree = ""; }; FD11E22F2CA4F498001BAF58 /* DestinationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestinationSpec.swift; sourceTree = ""; }; @@ -3716,6 +3740,7 @@ FD7F74682BAB8A5D006DDFD8 /* LibSession */, FD6B92DF2E77C1CB004463B5 /* PushNotification */, 947D7FD32D509FC900E8E413 /* SessionNetwork */, + FD0F855F2EA82C7B004E0B98 /* SessionPro */, FD6B92892E779D8D004463B5 /* SOGS */, FD2272842C33E28D004D8A6C /* StorageServer */, FD6B92A52E77A3BD004463B5 /* Models */, @@ -4055,6 +4080,40 @@ path = Models; sourceTree = ""; }; + FD0F855F2EA82C7B004E0B98 /* SessionPro */ = { + isa = PBXGroup; + children = ( + FD0F85692EA83518004E0B98 /* Requests */, + FD0F85642EA82FC2004E0B98 /* Types */, + FD0F85602EA82C87004E0B98 /* SessionPro.swift */, + FD0F85622EA82DF6004E0B98 /* SessionProAPI.swift */, + FD0F85672EA83382004E0B98 /* SessionProEndpoint.swift */, + ); + path = SessionPro; + sourceTree = ""; + }; + FD0F85642EA82FC2004E0B98 /* Types */ = { + isa = PBXGroup; + children = ( + FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, + FD0F85762EA83D8F004E0B98 /* ProProof.swift */, + FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */, + FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */, + FD0F856C2EA835B6004E0B98 /* Signatures.swift */, + FD0F856E2EA83661004E0B98 /* UserTransaction.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD0F85692EA83518004E0B98 /* Requests */ = { + isa = PBXGroup; + children = ( + FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */, + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */, + ); + path = Requests; + sourceTree = ""; + }; FD17D79427F3E03300122BE0 /* Migrations */ = { isa = PBXGroup; children = ( @@ -4193,6 +4252,7 @@ isa = PBXGroup; children = ( FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */, + FD0F85722EA83C41004E0B98 /* AnyCodable.swift */, FD0E353A2AB98773006A81F7 /* AppVersion.swift */, FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */, FDE755042C9BB4ED002A2623 /* Bencode.swift */, @@ -6406,6 +6466,7 @@ FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */, FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, + FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, @@ -6426,9 +6487,11 @@ FD6B92B12E77AA03004463B5 /* HTTPHeader+SOGS.swift in Sources */, FD6B92F72E77C6D7004463B5 /* Crypto+PushNotification.swift in Sources */, FD6B92B22E77AA03004463B5 /* UpdateTypes.swift in Sources */, + FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */, FD6B92B32E77AA03004463B5 /* Personalization.swift in Sources */, FD6B929B2E77A084004463B5 /* NetworkInfo.swift in Sources */, FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, + FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */, FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */, FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */, FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, @@ -6438,8 +6501,10 @@ FD2272B32C33E337004D8A6C /* BatchRequest.swift in Sources */, FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */, FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */, + FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */, FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, + FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, @@ -6450,6 +6515,7 @@ FD6B92F22E77C5D1004463B5 /* UnsubscribeResponse.swift in Sources */, 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */, 947D7FD72D509FC900E8E413 /* HTTPClient.swift in Sources */, + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGetProProofResponse.swift in Sources */, FD6B92B42E77AA11004463B5 /* PinnedMessage.swift in Sources */, FD6B92B52E77AA11004463B5 /* SendDirectMessageResponse.swift in Sources */, FD6B92B62E77AA11004463B5 /* UserUnbanRequest.swift in Sources */, @@ -6482,8 +6548,10 @@ FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */, FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, + FD0F85662EA82FCC004E0B98 /* PaymentProvider.swift in Sources */, FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, FD6B92C82E77AD39004463B5 /* Crypto+SOGS.swift in Sources */, + FD0F85632EA82DF9004E0B98 /* SessionProAPI.swift in Sources */, FD6B92942E77A003004463B5 /* SessionNetworkEndpoint.swift in Sources */, FDD20C182A09E7D3003898FB /* GetExpiriesResponse.swift in Sources */, FD2272BB2C33E337004D8A6C /* HTTPMethod.swift in Sources */, @@ -6493,10 +6561,12 @@ FDF848D929405C5B007DCAE5 /* SnodeAuthenticatedRequestBody.swift in Sources */, FD6B92AD2E77A9F1004463B5 /* SOGSError.swift in Sources */, FD2272BA2C33E337004D8A6C /* HTTPHeader.swift in Sources */, + FD0F856D2EA835C5004E0B98 /* Signatures.swift in Sources */, FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */, FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */, FD6B92C62E77AD0F004463B5 /* Crypto+FileServer.swift in Sources */, FD2286682C37DA3B00BC06F7 /* LibSession+Networking.swift in Sources */, + FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */, FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */, FD6B92E62E77C5A2004463B5 /* Service.swift in Sources */, FD6B92E72E77C5A2004463B5 /* Request+PushNotificationAPI.swift in Sources */, @@ -6515,6 +6585,7 @@ FD2272B42C33E337004D8A6C /* SwarmDrainBehaviour.swift in Sources */, 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */, FD2272AE2C33E337004D8A6C /* HTTPQueryParam.swift in Sources */, + FD0F85612EA82C8B004E0B98 /* SessionPro.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, FD2272C42C34E9AA004D8A6C /* BencodeResponse.swift in Sources */, ); @@ -6592,6 +6663,7 @@ FDE754DD2C9BAF8A002A2623 /* Mnemonic.swift in Sources */, FD52CB652E13B6E900A4DA70 /* ObservationBuilder.swift in Sources */, FDBEE52E2B6A18B900C143A0 /* UserDefaultsConfig.swift in Sources */, + FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */, FD78EA042DDEC3C500D55B50 /* MultiTaskManager.swift in Sources */, FD78EA062DDEC8F600D55B50 /* AsyncSequence+Utilities.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift index a96d9fd2d4..51b4f4b6de 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:ignore import Foundation diff --git a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift new file mode 100644 index 0000000000..1e3609ff58 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift @@ -0,0 +1,46 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct AddProPaymentOrGetProProofResponse: Decodable, Equatable { + public let header: ResponseHeader + public let proof: ProProof + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_add_pro_payment_or_get_pro_proof_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_add_pro_payment_or_get_pro_proof_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.proof = ProProof(result.proof) + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift b/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift new file mode 100644 index 0000000000..8d781cf829 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/AppProPaymentRequest.swift @@ -0,0 +1,42 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct AddProPaymentRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let rotatingPublicKey: [UInt8] + public let paymentTransaction: UserTransaction + public let signatures: Signatures + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_add_pro_payment_request { + var result: session_pro_backend_add_pro_payment_request = session_pro_backend_add_pro_payment_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.rotating_pkey, to: rotatingPublicKey) + result.payment_tx = paymentTransaction.toLibSession() + result.set(\.master_sig, to: signatures.masterSignature) + result.set(\.rotating_sig, to: signatures.rotatingSignature) + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_add_pro_payment_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_add_pro_payment_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_add_pro_payment_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/SessionPro.swift b/SessionNetworkingKit/SessionPro/SessionPro.swift new file mode 100644 index 0000000000..b129bca495 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionPro.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public extension Network { + enum SessionPro { + static let apiVersion: UInt8 = 0 + static let server = "{NEED_TO_SET}" + static let serverPublicKey = "{NEED_TO_SET}" + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift new file mode 100644 index 0000000000..bb37534c69 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -0,0 +1,66 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import SessionUtil +import SessionUtilitiesKit + +// MARK: - Log.Category + +public extension Log.Category { + static let sessionPro: Log.Category = .create("SessionPro", defaultLevel: .info) +} + +public extension Network.SessionPro { + static func test(using dependencies: Dependencies) throws -> Network.PreparedRequest { + let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey + + let cTransactionId: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(32)) + let transactionId: String = cTransactionId.toHexString() + + let cSigs: session_pro_backend_master_rotating_signatures = session_pro_backend_add_pro_payment_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + cRotatingPrivateKey, + cRotatingPrivateKey.count, + PaymentProvider.appStore.libSessionValue, + cTransactionId, + cTransactionId.count + ) + + let signatures: Signatures = try Signatures(cSigs) + let request: AddProPaymentRequest = AddProPaymentRequest( + masterPublicKey: masterKeyPair.publicKey, + rotatingPublicKey: rotatingKeyPair.publicKey, + paymentTransaction: UserTransaction( + provider: .appStore, + paymentId: cTransactionId.toHexString() + ), + signatures: signatures + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .addProPayment, + body: AddProPaymentRequest( + masterPublicKey: masterKeyPair.publicKey, + rotatingPublicKey: rotatingKeyPair.publicKey, + paymentTransaction: UserTransaction( + provider: .appStore, + paymentId: cTransactionId.toHexString() + ), + signatures: signatures + ) + ), + responseType: AddProPaymentOrGetProProofResponse.self, + using: dependencies + ) + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift new file mode 100644 index 0000000000..8d789a523c --- /dev/null +++ b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift @@ -0,0 +1,25 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:ignore + +import Foundation + +public extension Network.SessionPro { + enum Endpoint: EndpointType { + case addProPayment + case getProProof + case getProRevocations + case getProStatus + + public static var name: String { "SessionPro.Endpoint" } + + public var path: String { + switch self { + case .addProPayment: return "add_pro_payment" + case .getProProof: return "get_pro_proof" + case .getProRevocations: return "get_pro_revocations" + case .getProStatus: return "get_pro_status" + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift new file mode 100644 index 0000000000..a8e1f6e055 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift @@ -0,0 +1,29 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum PaymentProvider: CaseIterable { + case none + case playStore + case appStore + + var libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER { + switch self { + case .none: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL + case .playStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE + case .appStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL: self = .none + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE: self = .playStore + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE: self = .appStore + default: self = .none + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift new file mode 100644 index 0000000000..399d8accfd --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -0,0 +1,25 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct ProProof: Equatable { + let version: UInt8 + let genIndexHash: [UInt8] + let rotatingPubkey: [UInt8] + let expiryUnixTimestampMs: UInt64 + let signature: [UInt8] + + init(_ libSessionValue: session_protocol_pro_proof) { + version = libSessionValue.version + genIndexHash = libSessionValue.get(\.gen_index_hash) + rotatingPubkey = libSessionValue.get(\.rotating_pubkey) + expiryUnixTimestampMs = libSessionValue.expiry_unix_ts_ms + signature = libSessionValue.get(\.sig) + } + } +} + +extension session_protocol_pro_proof: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift new file mode 100644 index 0000000000..cdec321316 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension Request where Endpoint == Network.SessionPro.Endpoint { + init( + method: HTTPMethod, + endpoint: Endpoint, + queryParameters: [HTTPQueryParam: String] = [:], + headers: [HTTPHeader: String] = [:], + body: T? = nil + ) throws { + self = try Request( + endpoint: endpoint, + destination: try .server( + method: method, + server: Network.SessionPro.server, + queryParameters: queryParameters, + headers: headers, + x25519PublicKey: Network.SessionPro.serverPublicKey + ), + body: body + ) + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift new file mode 100644 index 0000000000..61aec2f16e --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + struct ResponseHeader: Equatable { + let status: UInt32 + let errors: [String] + + init(_ libSessionValue: session_pro_backend_response_header) { + status = libSessionValue.status + errors = (0.. session_pro_backend_add_pro_payment_user_transaction { + var result: session_pro_backend_add_pro_payment_user_transaction = session_pro_backend_add_pro_payment_user_transaction() + result.provider = provider.libSessionValue + result.set(\.payment_id, to: paymentId) + result.payment_id_count = paymentId.count + + return result + } + } +} + +extension session_pro_backend_add_pro_payment_user_transaction: @retroactive CAccessible & CMutable {} diff --git a/SessionNetworkingKit/Types/NetworkError.swift b/SessionNetworkingKit/Types/NetworkError.swift index 8938775521..8a91caf417 100644 --- a/SessionNetworkingKit/Types/NetworkError.swift +++ b/SessionNetworkingKit/Types/NetworkError.swift @@ -11,6 +11,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case forbidden case notFound case parsingFailed + case invalidPayload case invalidResponse case maxFileSizeExceeded case unauthorised @@ -32,6 +33,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .forbidden: return "Forbidden (NetworkError.forbidden)." case .notFound: return "Not Found (NetworkError.notFound)." case .parsingFailed: return "Invalid response (NetworkError.parsingFailed)." + case .invalidPayload: return "Invalid payload (NetworkError.invalidPayload)." case .invalidResponse: return "Invalid response (NetworkError.invalidResponse)." case .maxFileSizeExceeded: return "Maximum file size exceeded (NetworkError.maxFileSizeExceeded)." case .unauthorised: return "Unauthorised (Failed to verify the signature - NetworkError.unauthorised)." diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index 07abc996a0..4dbfae899b 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -151,72 +151,7 @@ public extension DataProtocol { // MARK: - CAccessible -public protocol CAccessible { - // General types - - func get(_ keyPath: KeyPath) -> T - - // String variants - - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> String - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - - // Data variants - - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - func get(_ keyPath: KeyPath) -> Data - func get(_ keyPath: KeyPath) -> [UInt8] - func getHex(_ keyPath: KeyPath) -> String - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? - func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> [UInt8]? - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? -} - +public protocol CAccessible {} public extension CAccessible { // General types @@ -227,6 +162,7 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } @@ -239,6 +175,9 @@ public extension CAccessible { func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -251,24 +190,18 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } - func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } - func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } - func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } - func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } - func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } - func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } @@ -279,15 +212,6 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } - } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -297,15 +221,6 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } - } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -315,52 +230,29 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } } // MARK: - CMutable -public protocol CMutable { - // General types - - mutating func set(_ keyPath: WritableKeyPath, to value: T) - - // String variants - - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - mutating func set(_ keyPath: WritableKeyPath, to value: String?) - - // Data variants - - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) - mutating func set(_ keyPath: WritableKeyPath, to value: T?) -} - +public protocol CMutable {} public extension CMutable { // General types @@ -374,27 +266,19 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { - withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } - } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { - withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } - } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + mutating func set(_ keyPath: WritableKeyPath, to value: T?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } - mutating func set(_ keyPath: WritableKeyPath, to value: T?) { + mutating func set(_ keyPath: WritableKeyPath, to value: D?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } @@ -412,6 +296,10 @@ public extension CMutable { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } + mutating func set(_ keyPath: WritableKeyPath, to value: String?) { + withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } + } + mutating func set(_ keyPath: WritableKeyPath, to value: String?) { withUnsafeMutablePointer(to: &self) { $0.set(keyPath, to: value) } } @@ -423,399 +311,264 @@ public extension CMutable { // MARK: - Pointer Convenience -public extension UnsafeMutablePointer { +public protocol ReadablePointer { + associatedtype Pointee + var ptr: Pointee { get } +} + +extension UnsafePointer: ReadablePointer { + public var ptr: Pointee { pointee } +} +extension UnsafeMutablePointer: ReadablePointer { + public var ptr: Pointee { pointee } +} + +public extension ReadablePointer { // General types - func get(_ keyPath: KeyPath) -> T { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> T { ptr[keyPath: keyPath] } // String variants - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> String { UnsafePointer(self).get(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } + func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getCString(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getCString(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getCString(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getCString(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getCString(keyPath, nullIfEmpty: nullIfEmpty) } // Data variants - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } - func get(_ keyPath: KeyPath) -> Data { UnsafePointer(self).get(keyPath) } - func get(_ keyPath: KeyPath) -> [UInt8] { UnsafePointer(self).get(keyPath) } - func getHex(_ keyPath: KeyPath) -> String { UnsafePointer(self).getHex(keyPath) } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty) } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - UnsafePointer(self).get(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } } func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - UnsafePointer(self).getHex(keyPath, nullIfEmpty: nullIfEmpty) + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } } public extension UnsafeMutablePointer { // General types - func set(_ keyPath: WritableKeyPath, to value: T) { pointee[keyPath: keyPath] = value } + func set(_ keyPath: WritableKeyPath, to value: T) { + var mutablePointee: Pointee = pointee + mutablePointee[keyPath: keyPath] = value + pointee = mutablePointee + } // String variants - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 65) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 67) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 101) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 224) } - func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value, maxLength: 268) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } + func set(_ keyPath: WritableKeyPath, to value: String?) { setCString(keyPath, value) } // Data variants func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 32) - } - - func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 32) + setData(keyPath, value.map { Data($0) }) } func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 33) - } - - func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 33) + setData(keyPath, value.map { Data($0) }) } func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 64) + setData(keyPath, value.map { Data($0) }) } - func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 64) + func set(_ keyPath: WritableKeyPath, to value: T?) { + setData(keyPath, value.map { Data($0) }) } - func set(_ keyPath: WritableKeyPath, to value: T?) { - setData(keyPath, value.map { Data($0) }, length: 100) + func set(_ keyPath: WritableKeyPath, to value: D?) { + setData(keyPath, value.map { Data($0) }) } } -public extension UnsafePointer { - // General types - - func get(_ keyPath: KeyPath) -> T { pointee[keyPath: keyPath] } - - // String variants - - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 65) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 67) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 101) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 224) } - func get(_ keyPath: KeyPath) -> String { getCString(keyPath, maxLength: 268) } - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 65, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 67, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 101, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 224, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, maxLength: 268, nullIfEmpty: nullIfEmpty) +// MARK: - Internal Logic + +private extension ReadablePointer { + func _getData(_ byteArray: T) -> Data { + return withUnsafeBytes(of: byteArray) { Data($0) } } - // Data variants - - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 32) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 32)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 32).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 32) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 32)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 32).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 33) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 33)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 33).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 33) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 33)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 33).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 64) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 64)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 64).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 64) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 64)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 64).toHexString() } - func get(_ keyPath: KeyPath) -> Data { getData(keyPath, length: 100) } - func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath, length: 100)) } - func getHex(_ keyPath: KeyPath) -> String { getData(keyPath, length: 100).toHexString() } - - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { Array($0) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { Array($0) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 32, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { Array($0) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { Array($0) } - } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 33, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty) - } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { Array($0) } + func _getData(_ byteArray: T, nullIfEmpty: Bool) -> Data? { + let result: Data = _getData(byteArray) + + return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func _string(from value: T) -> String { + withUnsafeBytes(of: value) { rawBufferPointer in + guard let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: CChar.self) else { + return "" + } + + return String(cString: buffer) + } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty) + + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath]) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { Array($0) } + + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath], nullIfEmpty: nullIfEmpty) } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 64, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath].data) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty) + + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath].data, nullIfEmpty: nullIfEmpty) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty).map { Array($0) } + + func getCString(_ keyPath: KeyPath) -> String { + return _string(from: ptr[keyPath: keyPath]) } - func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getData(keyPath, length: 100, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + + func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { + let result: String = _string(from: ptr[keyPath: keyPath]) + + return (!nullIfEmpty || !result.isEmpty ? result : nil) } } -// MARK: - Internal Logic - private extension UnsafeMutablePointer { - private func getData(_ keyPath: KeyPath, length: Int) -> Data { - return UnsafePointer(self).getData(keyPath, length: length) - } - - private func getData(_ keyPath: KeyPath, length: Int, nullIfEmpty: Bool) -> Data? { - return UnsafePointer(self).getData(keyPath, length: length, nullIfEmpty: nullIfEmpty) - } - - private func setData(_ keyPath: WritableKeyPath, _ value: Data?, length: Int) { - if let value: Data = value, value.count > length { - Log.warn("Setting \(keyPath) to data with \(value.count) length, expected: \(length), value will be truncated.") - } - + private func setData(_ keyPath: WritableKeyPath, _ value: Data?) { var mutableSelf = pointee withUnsafeMutableBytes(of: &mutableSelf[keyPath: keyPath]) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return } + rawBufferPointer.initializeMemory(as: UInt8.self, repeating: 0) - let buffer = baseAddress.assumingMemoryBound(to: UInt8.self) - guard let value: Data = value else { - // Zero-fill the data - memset(buffer, 0, length) - return - } - - value.copyBytes(to: buffer, count: min(length, value.count)) - - if value.count < length { - // Zero-fill any remaining bytes - memset(buffer.advanced(by: value.count), 0, length - value.count) + if + let value: Data = value, + let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self) + { + if value.count > rawBufferPointer.count { + Log.warn("Setting \(keyPath) to data with \(value.count) length, expected: \(rawBufferPointer.count), value will be truncated.") + } + + let copyCount: Int = min(rawBufferPointer.count, value.count) + value.copyBytes(to: buffer, count: copyCount) } } pointee = mutableSelf } - private func getCString(_ keyPath: KeyPath, maxLength: Int) -> String { - return UnsafePointer(self).getCString(keyPath, maxLength: maxLength) - } - - private func getCString(_ keyPath: KeyPath, maxLength: Int, nullIfEmpty: Bool) -> String? { - return UnsafePointer(self).getCString(keyPath, maxLength: maxLength, nullIfEmpty: nullIfEmpty) - } - - private func setCString(_ keyPath: WritableKeyPath, _ value: String?, maxLength: Int) { + private func setCString(_ keyPath: WritableKeyPath, _ value: String?) { var mutableSelf = pointee withUnsafeMutableBytes(of: &mutableSelf[keyPath: keyPath]) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return } + rawBufferPointer.initializeMemory(as: UInt8.self, repeating: 0) - let buffer: UnsafeMutablePointer = baseAddress.assumingMemoryBound(to: CChar.self) - guard let value: String = value else { - // Zero-fill the data - memset(buffer, 0, maxLength) - return + if + let value: String = value, + let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: UInt8.self), + let cData: Data = value.data(using: .utf8) + { + let copyCount: Int = min(rawBufferPointer.count - 1, cData.count) + cData.copyBytes(to: buffer, count: copyCount) } - guard let nullTerminatedString: [CChar] = value.cString(using: .utf8) else { return } - - let copyLength: Int = min(maxLength - 1, nullTerminatedString.count - 1) - strncpy(buffer, nullTerminatedString, copyLength) - buffer[copyLength] = 0 // Ensure null termination } pointee = mutableSelf } } -private extension UnsafePointer { - func getData(_ keyPath: KeyPath, length: Int) -> Data { - let byteArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: byteArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return Data() } - - return Data(bytes: baseAddress, count: length) - } - } - - func getData(_ keyPath: KeyPath, length: Int, nullIfEmpty: Bool) -> Data? { - let byteArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: byteArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return nil } - - let result: Data = Data(bytes: baseAddress, count: length) - - // If all of the values are 0 then return the data as null - guard !nullIfEmpty || result.contains(where: { $0 != 0 }) else { return nil } - - return result - } - } - - func getCString(_ keyPath: KeyPath, maxLength: Int) -> String { - let charArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: charArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return "" } - - let buffer = baseAddress.assumingMemoryBound(to: CChar.self) - return String(cString: buffer) - } - } - - func getCString(_ keyPath: KeyPath, maxLength: Int, nullIfEmpty: Bool) -> String? { - let charArray = pointee[keyPath: keyPath] - return withUnsafeBytes(of: charArray) { rawBufferPointer in - guard let baseAddress = rawBufferPointer.baseAddress else { return nil } - - let buffer = baseAddress.assumingMemoryBound(to: CChar.self) - let result: String = String(cString: buffer) - - guard !nullIfEmpty || !result.isEmpty else { return nil } - - return result - } - } +// MARK: - Explicit C Struct Types + +public protocol CTupleWrapper { + associatedtype TupleType + var data: TupleType { get set } +} + +extension bytes32: CTupleWrapper { + public typealias TupleType = CUChar32 +} + +extension bytes33: CTupleWrapper { + public typealias TupleType = CUChar33 +} + +extension bytes64: CTupleWrapper { + public typealias TupleType = CUChar64 } // MARK: - Fixed Length Types @@ -891,6 +644,22 @@ public typealias CChar101 = ( CChar ) +public typealias CChar128 = ( + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, + CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar +) + public typealias CChar224 = ( CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, diff --git a/SessionUtilitiesKit/Types/AnyCodable.swift b/SessionUtilitiesKit/Types/AnyCodable.swift new file mode 100644 index 0000000000..6a5ab329c6 --- /dev/null +++ b/SessionUtilitiesKit/Types/AnyCodable.swift @@ -0,0 +1,62 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } + else if let int = try? container.decode(Int.self) { + value = int + } + else if let double = try? container.decode(Double.self) { + value = double + } + else if let string = try? container.decode(String.self) { + value = string + } + else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } + else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } + else if container.decodeNil() { + value = NSNull() + } + else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON type" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container: SingleValueEncodingContainer = encoder.singleValueContainer() + + switch value { + case let bool as Bool: try container.encode(bool) + case let int as Int: try container.encode(int) + case let double as Double: try container.encode(double) + case let string as String: try container.encode(string) + case let array as [Any]: try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: try container.encode(dict.mapValues { AnyCodable($0) }) + case is NSNull: try container.encodeNil() + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type") + ) + } + } +} From 767ef8b8c65b93a7cb1cbd2a3556a2e6fb77c033 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 12:25:15 +1100 Subject: [PATCH 09/66] Added some more Pro API call wrappers, fixed issue with initial tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added the get pro proof request • Added the get pro status request • Cleaned up the API to be closer to the proper structure • Fixed an issue with the add pro payment request --- Session.xcodeproj/project.pbxproj | 36 ++++ .../DeveloperSettingsProViewModel.swift | 181 +++++++++++++++++- .../Requests/GetProProofRequest.swift | 42 ++++ .../Requests/GetProStatusRequest.swift | 41 ++++ .../Requests/GetProStatusResponse.swift | 86 +++++++++ .../SessionPro/SessionProAPI.swift | 113 ++++++++--- .../SessionPro/SessionProEndpoint.swift | 2 +- .../SessionPro/Types/PaymentItem.swift | 71 +++++++ .../Types/PaymentProviderMetadata.swift | 43 +++++ .../SessionPro/Types/PaymentStatus.swift | 35 ++++ .../SessionPro/Types/Plan.swift | 32 ++++ .../SessionPro/Types/ProStatus.swift | 37 ++++ .../SessionPro/Types/ResponseHeader.swift | 4 +- .../SessionPro/Types/Signature.swift | 22 +++ SessionUtilitiesKit/Crypto/KeyPair.swift | 2 +- 15 files changed, 717 insertions(+), 30 deletions(-) create mode 100644 SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/PaymentItem.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/Plan.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/ProStatus.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/Signature.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 94767370ec..8ee8fe2fa6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -603,6 +603,15 @@ FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; + FD306BCC2EB02D9E00ADB003 /* GetProStatusRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */; }; + FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; + FD306BD02EB02F3900ADB003 /* GetProStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */; }; + FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD12EB031AB00ADB003 /* PaymentItem.swift */; }; + FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */; }; + FD306BD62EB0323000ADB003 /* ProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* ProStatus.swift */; }; + FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD72EB033CB00ADB003 /* Plan.swift */; }; + FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */; }; + FD306BDC2EB0436C00ADB003 /* GetProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */; }; FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; @@ -1974,6 +1983,15 @@ FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; + FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProStatusRequest.swift; sourceTree = ""; }; + FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; + FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProStatusResponse.swift; sourceTree = ""; }; + FD306BD12EB031AB00ADB003 /* PaymentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentItem.swift; sourceTree = ""; }; + FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatus.swift; sourceTree = ""; }; + FD306BD52EB0322E00ADB003 /* ProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProStatus.swift; sourceTree = ""; }; + FD306BD72EB033CB00ADB003 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; + FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderMetadata.swift; sourceTree = ""; }; + FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProProofRequest.swift; sourceTree = ""; }; FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPollerCache.swift; sourceTree = ""; }; @@ -4095,10 +4113,16 @@ FD0F85642EA82FC2004E0B98 /* Types */ = { isa = PBXGroup; children = ( + FD306BD12EB031AB00ADB003 /* PaymentItem.swift */, FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, + FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */, + FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */, + FD306BD72EB033CB00ADB003 /* Plan.swift */, FD0F85762EA83D8F004E0B98 /* ProProof.swift */, + FD306BD52EB0322E00ADB003 /* ProStatus.swift */, FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */, FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */, + FD306BCD2EB02E3400ADB003 /* Signature.swift */, FD0F856C2EA835B6004E0B98 /* Signatures.swift */, FD0F856E2EA83661004E0B98 /* UserTransaction.swift */, ); @@ -4110,6 +4134,9 @@ children = ( FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */, FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */, + FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */, + FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */, + FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */, ); path = Requests; sourceTree = ""; @@ -6452,11 +6479,13 @@ buildActionMask = 2147483647; files = ( FD6B92E12E77C1E1004463B5 /* PushNotification.swift in Sources */, + FD306BD02EB02F3900ADB003 /* GetProStatusResponse.swift in Sources */, FD2272B12C33E337004D8A6C /* ProxiedContentDownloader.swift in Sources */, FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, FD6B928E2E779E99004463B5 /* FileServerEndpoint.swift in Sources */, FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, FD2272AB2C33E337004D8A6C /* ValidatableResponse.swift in Sources */, + FD306BCC2EB02D9E00ADB003 /* GetProStatusRequest.swift in Sources */, FD2272B72C33E337004D8A6C /* Request.swift in Sources */, FD2272B92C33E337004D8A6C /* ResponseInfo.swift in Sources */, FD6B92F82E77C725004463B5 /* ProcessResult.swift in Sources */, @@ -6469,6 +6498,7 @@ FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */, + FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, @@ -6480,6 +6510,7 @@ FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */, FD6B92992E77A06E004463B5 /* Token.swift in Sources */, FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */, + FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */, FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, FD6B92AF2E77AA03004463B5 /* HTTPQueryParam+SOGS.swift in Sources */, @@ -6493,23 +6524,27 @@ FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */, FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */, + FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */, FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */, FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, FD02CC162C3681EF009AB976 /* RevokeSubaccountResponse.swift in Sources */, FDE754E32C9BAFF4002A2623 /* Crypto+SessionNetworkingKit.swift in Sources */, FD2272AD2C33E337004D8A6C /* Network.swift in Sources */, FD2272B32C33E337004D8A6C /* BatchRequest.swift in Sources */, + FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */, FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */, FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */, FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */, FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, + FD306BD62EB0323000ADB003 /* ProStatus.swift in Sources */, FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, FD6B92EA2E77C5D1004463B5 /* NotificationMetadata.swift in Sources */, FD6B92EB2E77C5D1004463B5 /* AuthenticatedRequest.swift in Sources */, + FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */, FD6B92EF2E77C5D1004463B5 /* UnsubscribeRequest.swift in Sources */, FD6B92F02E77C5D1004463B5 /* SubscribeRequest.swift in Sources */, FD6B92F22E77C5D1004463B5 /* UnsubscribeResponse.swift in Sources */, @@ -6530,6 +6565,7 @@ FD6B92BF2E77AA11004463B5 /* DeleteInboxResponse.swift in Sources */, FD6B92C02E77AA11004463B5 /* SendDirectMessageRequest.swift in Sources */, FD6B92C12E77AA11004463B5 /* CapabilitiesResponse.swift in Sources */, + FD306BDC2EB0436C00ADB003 /* GetProProofRequest.swift in Sources */, FD6B92C22E77AA11004463B5 /* SOGSMessage.swift in Sources */, 947D7FD82D509FC900E8E413 /* KeyValueStore+SessionNetwork.swift in Sources */, FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 13b596b8ae..c4960f8207 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -74,6 +74,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case manageProSubscriptions case restoreProSubscription case requestRefund + case submitPurchaseToProBackend + case refreshProStatus case proStatus case proIncomingMessages @@ -90,6 +92,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" case .requestRefund: return "requestRefund" + case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" + case .refreshProStatus: return "refreshProStatus" case .proStatus: return "proStatus" case .proIncomingMessages: return "proIncomingMessages" @@ -109,6 +113,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough + case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough + case .refreshProStatus: result.append(.refreshProStatus); fallthrough case .proStatus: result.append(.proStatus); fallthrough case .proIncomingMessages: result.append(.proIncomingMessages) @@ -121,6 +127,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum DeveloperSettingsProEvent: Hashable { case purchasedProduct([Product], Product?, String?, String?, Transaction?) case refundTransaction(Transaction.RefundRequestStatus) + case submittedTranasction(KeyPair?, KeyPair?, String?, Bool) + case currentProStatus(String?, Bool) } // MARK: - Content @@ -135,6 +143,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchaseTransaction: Transaction? let refundRequestStatus: Transaction.RefundRequestStatus? + let submittedTransactionMasterKeyPair: KeyPair? + let submittedTransactionRotatingKeyPair: KeyPair? + let submittedTransactionStatus: String? + let submittedTransactionErrored: Bool + + let currentProStatus: String? + let currentProStatusErrored: Bool + let mockCurrentUserSessionPro: Bool let treatAllIncomingMessagesAsProMessages: Bool @@ -164,6 +180,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransaction: nil, refundRequestStatus: nil, + submittedTransactionMasterKeyPair: nil, + submittedTransactionRotatingKeyPair: nil, + submittedTransactionStatus: nil, + submittedTransactionErrored: false, + + currentProStatus: nil, + currentProStatusErrored: false, + mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] ) @@ -184,6 +208,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var purchaseStatus: String? = previousState.purchaseStatus var purchaseTransaction: Transaction? = previousState.purchaseTransaction var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus + var submittedTransactionMasterKeyPair: KeyPair? = previousState.submittedTransactionMasterKeyPair + var submittedTransactionRotatingKeyPair: KeyPair? = previousState.submittedTransactionRotatingKeyPair + var submittedTransactionStatus: String? = previousState.submittedTransactionStatus + var submittedTransactionErrored: Bool = previousState.submittedTransactionErrored + var currentProStatus: String? = previousState.currentProStatus + var currentProStatusErrored: Bool = previousState.currentProStatusErrored events.forEach { event in guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } @@ -198,6 +228,16 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .refundTransaction(let status): refundRequestStatus = status + + case .submittedTranasction(let masterKeyPair, let rotatingKeyPair, let status, let errored): + submittedTransactionMasterKeyPair = masterKeyPair + submittedTransactionRotatingKeyPair = rotatingKeyPair + submittedTransactionStatus = status + submittedTransactionErrored = errored + + case .currentProStatus(let status, let errored): + currentProStatus = status + currentProStatusErrored = errored } } @@ -209,6 +249,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseStatus: purchaseStatus, purchaseTransaction: purchaseTransaction, refundRequestStatus: refundRequestStatus, + submittedTransactionMasterKeyPair: submittedTransactionMasterKeyPair, + submittedTransactionRotatingKeyPair: submittedTransactionRotatingKeyPair, + submittedTransactionStatus: submittedTransactionStatus, + submittedTransactionErrored: submittedTransactionErrored, + currentProStatus: currentProStatus, + currentProStatusErrored: currentProStatusErrored, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] ) @@ -265,6 +311,24 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold @unknown default: return "N/A" } }() + let rotatingPubkey: String = ( + (state.submittedTransactionRotatingKeyPair?.publicKey).map { "\($0.toHexString())" } ?? + "N/A" + ) + let submittedTransactionStatus: String = { + switch (state.submittedTransactionStatus, state.submittedTransactionErrored) { + case (.some(let error), true): return "\(error)" + case (.some(let status), false): return "\(status)" + case (.none, _): return "None" + } + }() + let currentProStatus: String = { + switch (state.currentProStatus, state.currentProStatusErrored) { + case (.some(let error), true): return "\(error)" + case (.some(let status), false): return "\(status)" + case (.none, _): return "Unknown" + } + }() let subscriptions: SectionModel = SectionModel( model: .subscriptions, elements: [ @@ -313,13 +377,42 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Request a refund for a Session Pro subscription via the App Store. - Status:\(refundStatus) + Status: \(refundStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Request"), isEnabled: (state.purchaseTransaction != nil), onTap: { [weak viewModel] in Task { await viewModel?.requestRefund() } } + ), + SessionCell.Info( + id: .submitPurchaseToProBackend, + title: "Submit Purchase to Pro Backend", + subtitle: """ + Submit a purchase to the Session Pro Backend. + + Rotating Pubkey: \(rotatingPubkey) + Status: \(submittedTransactionStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Submit"), + isEnabled: (state.purchaseTransaction != nil), + onTap: { [weak viewModel] in + Task { await viewModel?.submitTransactionToProBackend() } + } + ), + SessionCell.Info( + id: .refreshProStatus, + title: "Refresh Pro Status", + subtitle: """ + Refresh the pro status. + + Status: \(currentProStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Refresh"), + isEnabled: (state.submittedTransactionMasterKeyPair != nil), + onTap: { [weak viewModel] in + Task { await viewModel?.refreshProStatus() } + } ) ] ) @@ -486,4 +579,90 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Log.error("[DevSettings] Unable to request refund: \(error)") } } + + private func submitTransactionToProBackend() async { + guard let transaction: Transaction = await internalState.purchaseTransaction else { return } + + do { + let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let request = try? Network.SessionPro.addProPaymentOrGetProProof( + transactionId: "\(transaction.id)", + masterKeyPair: masterKeyPair, + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + Log.error("[DevSettings] Tranasction submission failed: \(response.header.errors[0])") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.submittedTranasction( + masterKeyPair, + rotatingKeyPair, + "Failed: \(response.header.errors[0])", + true + ) + ) + return + } + + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.submittedTranasction(masterKeyPair, rotatingKeyPair, "Success", false) + ) + } + catch { + Log.error("[DevSettings] Tranasction submission failed: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.submittedTranasction(nil, nil, "Failed: \(error)", true) + ) + } + } + + private func refreshProStatus() async { + guard let masterKeyPair: KeyPair = await internalState.submittedTransactionMasterKeyPair else { return } + + do { + let request = try? Network.SessionPro.getProStatus( + masterKeyPair: masterKeyPair, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + Log.error("[DevSettings] Refresh pro status failed: \(response.header.errors[0])") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.currentProStatus( + "Error: \(response.header.errors[0])", + true + ) + ) + return + } + + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.currentProStatus("\(response.status)", false) + ) + } + catch { + Log.error("[DevSettings] Refresh pro status failed: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.currentProStatus("Error: \(error)", true) + ) + } + } } diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift new file mode 100644 index 0000000000..b8916adf8a --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift @@ -0,0 +1,42 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProProofRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let rotatingPublicKey: [UInt8] + public let timestampMs: UInt64 + public let signatures: Signatures + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_get_pro_proof_request { + var result: session_pro_backend_get_pro_proof_request = session_pro_backend_get_pro_proof_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.rotating_pkey, to: rotatingPublicKey) + result.unix_ts_ms = timestampMs + result.set(\.master_sig, to: signatures.masterSignature) + result.set(\.rotating_sig, to: signatures.rotatingSignature) + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_get_pro_proof_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_proof_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_get_pro_proof_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift new file mode 100644 index 0000000000..9a70c4091a --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift @@ -0,0 +1,41 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProStatusRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let timestampMs: UInt64 + public let includeHistory: Bool + public let signature: Signature + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_get_pro_status_request { + var result: session_pro_backend_get_pro_status_request = session_pro_backend_get_pro_status_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.master_sig, to: signature.signature) + result.unix_ts_ms = timestampMs + result.history = includeHistory + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_get_pro_status_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_status_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_get_pro_status_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift new file mode 100644 index 0000000000..0ea9ff8f18 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift @@ -0,0 +1,86 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProStatusResponse: Decodable, Equatable { + public let header: ResponseHeader + public let items: [PaymentItem] + public let status: ProStatus + public let errorReport: ErrorReport + public let autoRenewing: Bool + public let expiryTimestampMs: UInt64 + public let gracePeriodDurationMs: UInt64 + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_get_pro_status_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_get_pro_status_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.status = ProStatus(result.status) + self.errorReport = ErrorReport(result.error_report) + self.autoRenewing = result.auto_renewing + self.expiryTimestampMs = result.expiry_unix_ts_ms + self.gracePeriodDurationMs = result.grace_period_duration_ms + + if result.items_count > 0 { + self.items = (0.. Network.PreparedRequest { let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + static func addProPaymentOrGetProProof( + transactionId: String, + masterKeyPair: KeyPair, + rotatingKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey - - let cTransactionId: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(32)) - let transactionId: String = cTransactionId.toHexString() - - let cSigs: session_pro_backend_master_rotating_signatures = session_pro_backend_add_pro_payment_request_build_sigs( - Network.SessionPro.apiVersion, - cMasterPrivateKey, - cMasterPrivateKey.count, - cRotatingPrivateKey, - cRotatingPrivateKey.count, - PaymentProvider.appStore.libSessionValue, - cTransactionId, - cTransactionId.count - ) - - let signatures: Signatures = try Signatures(cSigs) - let request: AddProPaymentRequest = AddProPaymentRequest( - masterPublicKey: masterKeyPair.publicKey, - rotatingPublicKey: rotatingKeyPair.publicKey, - paymentTransaction: UserTransaction( - provider: .appStore, - paymentId: cTransactionId.toHexString() - ), - signatures: signatures + let cTransactionId: [UInt8] = Array(transactionId.utf8) + let signatures: Signatures = try Signatures( + session_pro_backend_add_pro_payment_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + cRotatingPrivateKey, + cRotatingPrivateKey.count, + PaymentProvider.appStore.libSessionValue, + cTransactionId, + cTransactionId.count + ) ) return try Network.PreparedRequest( @@ -54,7 +48,7 @@ public extension Network.SessionPro { rotatingPublicKey: rotatingKeyPair.publicKey, paymentTransaction: UserTransaction( provider: .appStore, - paymentId: cTransactionId.toHexString() + paymentId: transactionId ), signatures: signatures ) @@ -63,4 +57,73 @@ public extension Network.SessionPro { using: dependencies ) } + + static func getProProof( + masterKeyPair: KeyPair, + rotatingKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let signatures: Signatures = try Signatures( + session_pro_backend_get_pro_proof_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + cRotatingPrivateKey, + cRotatingPrivateKey.count, + timestampMs + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProProof, + body: GetProProofRequest( + masterPublicKey: masterKeyPair.publicKey, + rotatingPublicKey: rotatingKeyPair.publicKey, + timestampMs: timestampMs, + signatures: signatures + ) + ), + responseType: AddProPaymentOrGetProProofResponse.self, + using: dependencies + ) + } + + static func getProStatus( + includeHistory: Bool = false, + page: UInt32 = 0, + masterKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let signature: Signature = try Signature( + session_pro_backend_get_pro_status_request_build_sig( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + timestampMs, + page + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProStatus, + body: GetProStatusRequest( + masterPublicKey: masterKeyPair.publicKey, + timestampMs: timestampMs, + includeHistory: includeHistory, + signature: signature + ) + ), + responseType: GetProStatusResponse.self, + using: dependencies + ) + } } diff --git a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift index 8d789a523c..0103f06390 100644 --- a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift +++ b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift @@ -1,6 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. // -// stringlint:ignore +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift new file mode 100644 index 0000000000..f2bb07257c --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift @@ -0,0 +1,71 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct PaymentItem: Equatable { + let status: PaymentStatus + let plan: Plan + let paymentProvider: PaymentProvider + let paymentProviderMetadata: PaymentProviderMetadata? + + let autoRenewing: Bool + let unredeemedTimestampMs: UInt64 + let redeemedTimestampMs: UInt64 + let expiryTimestampMs: UInt64 + let gracePeriodDurationMs: UInt64 + let platformRefundExpiryTimestampMs: UInt64 + let revokedTimestampMs: UInt64 + + let googlePaymentToken: String? + let googleOrderId: String? + let appleOriginalTransactionId: String? + let appleTransactionId: String? + let appleWebLineOrderId: String? + + init(_ libSessionValue: session_pro_backend_pro_payment_item) { + status = PaymentStatus(libSessionValue.status) + plan = Plan(libSessionValue.plan) + paymentProvider = PaymentProvider(libSessionValue.payment_provider) + paymentProviderMetadata = PaymentProviderMetadata(libSessionValue.payment_provider_metadata) + + autoRenewing = libSessionValue.auto_renewing + unredeemedTimestampMs = libSessionValue.unredeemed_unix_ts_ms + redeemedTimestampMs = libSessionValue.redeemed_unix_ts_ms + expiryTimestampMs = libSessionValue.expiry_unix_ts_ms + gracePeriodDurationMs = libSessionValue.grace_period_duration_ms + platformRefundExpiryTimestampMs = libSessionValue.platform_refund_expiry_unix_ts_ms + revokedTimestampMs = libSessionValue.revoked_unix_ts_ms + + googlePaymentToken = libSessionValue.get( + \.google_payment_token, + nullIfEmpty: true, + explicitLength: libSessionValue.google_payment_token_count + ) + googleOrderId = libSessionValue.get( + \.google_order_id, + nullIfEmpty: true, + explicitLength: libSessionValue.google_order_id_count + ) + appleOriginalTransactionId = libSessionValue.get( + \.apple_original_tx_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_original_tx_id_count + ) + appleTransactionId = libSessionValue.get( + \.apple_tx_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_tx_id_count + ) + appleWebLineOrderId = libSessionValue.get( + \.apple_web_line_order_id, + nullIfEmpty: true, + explicitLength: libSessionValue.apple_web_line_order_id_count + ) + } + } +} + +extension session_pro_backend_pro_payment_item: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift new file mode 100644 index 0000000000..172c5aa540 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift @@ -0,0 +1,43 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct PaymentProviderMetadata: Equatable { + let device: String + let store: String + let platform: String + let platformAccount: String + let refundUrl: String + + /// Some platforms disallow a refund via their native support channels after some time period + /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers + /// themselves). If a platform does not have this restriction, this URL is typically the same as + /// the `refund_url`. + let refundAfterPlatformDeadlineUrl: String + + let refundSupportUrl: String + let updateSubscriptionUrl: String + let cancelSubscriptionUrl: String + + init?(_ pointer: UnsafePointer?) { + guard let libSessionValue: session_pro_backend_payment_provider_metadata = pointer?.pointee else { + return nil + } + + device = libSessionValue.get(\.device) + store = libSessionValue.get(\.store) + platform = libSessionValue.get(\.platform) + platformAccount = libSessionValue.get(\.platform_account) + refundUrl = libSessionValue.get(\.refund_url) + refundAfterPlatformDeadlineUrl = libSessionValue.get(\.refund_after_platform_deadline_url) + refundSupportUrl = libSessionValue.get(\.refund_support_url) + updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) + cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) + } + } +} + +extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift new file mode 100644 index 0000000000..909ac56112 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum PaymentStatus: CaseIterable { + case none + case unredeemed + case redeemed + case expired + case refunded + + var libSessionValue: SESSION_PRO_BACKEND_PAYMENT_STATUS { + switch self { + case .none: return SESSION_PRO_BACKEND_PAYMENT_STATUS_NIL + case .unredeemed: return SESSION_PRO_BACKEND_PAYMENT_STATUS_UNREDEEMED + case .redeemed: return SESSION_PRO_BACKEND_PAYMENT_STATUS_REDEEMED + case .expired: return SESSION_PRO_BACKEND_PAYMENT_STATUS_EXPIRED + case .refunded: return SESSION_PRO_BACKEND_PAYMENT_STATUS_REFUNDED + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PAYMENT_STATUS_NIL: self = .none + case SESSION_PRO_BACKEND_PAYMENT_STATUS_UNREDEEMED: self = .unredeemed + case SESSION_PRO_BACKEND_PAYMENT_STATUS_REDEEMED: self = .redeemed + case SESSION_PRO_BACKEND_PAYMENT_STATUS_EXPIRED: self = .expired + case SESSION_PRO_BACKEND_PAYMENT_STATUS_REFUNDED: self = .refunded + default: self = .none + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/Plan.swift b/SessionNetworkingKit/SessionPro/Types/Plan.swift new file mode 100644 index 0000000000..758db75299 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/Plan.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum Plan: CaseIterable { + case none + case oneMonth + case threeMonths + case twelveMonths + + var libSessionValue: SESSION_PRO_BACKEND_PLAN { + switch self { + case .none: return SESSION_PRO_BACKEND_PLAN_NIL + case .oneMonth: return SESSION_PRO_BACKEND_PLAN_ONE_MONTH + case .threeMonths: return SESSION_PRO_BACKEND_PLAN_THREE_MONTHS + case .twelveMonths: return SESSION_PRO_BACKEND_PLAN_TWELVE_MONTHS + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_PLAN) { + switch libSessionValue { + case SESSION_PRO_BACKEND_PLAN_NIL: self = .none + case SESSION_PRO_BACKEND_PLAN_ONE_MONTH: self = .oneMonth + case SESSION_PRO_BACKEND_PLAN_THREE_MONTHS: self = .threeMonths + case SESSION_PRO_BACKEND_PLAN_TWELVE_MONTHS: self = .twelveMonths + default: self = .none + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ProStatus.swift b/SessionNetworkingKit/SessionPro/Types/ProStatus.swift new file mode 100644 index 0000000000..7fd1c8ed16 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/ProStatus.swift @@ -0,0 +1,37 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum ProStatus: CaseIterable, CustomStringConvertible { + case neverBeenPro + case active + case expired + + var libSessionValue: SESSION_PRO_BACKEND_USER_PRO_STATUS { + switch self { + case .neverBeenPro: return SESSION_PRO_BACKEND_USER_PRO_STATUS_NEVER_BEEN_PRO + case .active: return SESSION_PRO_BACKEND_USER_PRO_STATUS_ACTIVE + case .expired: return SESSION_PRO_BACKEND_USER_PRO_STATUS_EXPIRED + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_USER_PRO_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_USER_PRO_STATUS_NEVER_BEEN_PRO: self = .neverBeenPro + case SESSION_PRO_BACKEND_USER_PRO_STATUS_ACTIVE: self = .active + case SESSION_PRO_BACKEND_USER_PRO_STATUS_EXPIRED: self = .expired + default: self = .neverBeenPro + } + } + + public var description: String { + switch self { + case .neverBeenPro: return "Never been pro" + case .active: return "Active" + case .expired: return "Expired" + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift index 61aec2f16e..61415eb057 100644 --- a/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift +++ b/SessionNetworkingKit/SessionPro/Types/ResponseHeader.swift @@ -5,8 +5,8 @@ import SessionUtil public extension Network.SessionPro { struct ResponseHeader: Equatable { - let status: UInt32 - let errors: [String] + public let status: UInt32 + public let errors: [String] init(_ libSessionValue: session_pro_backend_response_header) { status = libSessionValue.status diff --git a/SessionNetworkingKit/SessionPro/Types/Signature.swift b/SessionNetworkingKit/SessionPro/Types/Signature.swift new file mode 100644 index 0000000000..4e4cc7f3ce --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/Signature.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct Signature: Equatable { + public let signature: [UInt8] + + init(_ libSessionValue: session_pro_backend_signature) throws { + guard libSessionValue.success else { + Log.error([.network, .sessionPro], "Failed to build signature: \(libSessionValue.get(\.error))") + throw CryptoError.signatureGenerationFailed + } + + signature = libSessionValue.get(\.sig) + } + } +} + +extension session_pro_backend_signature: @retroactive CAccessible {} diff --git a/SessionUtilitiesKit/Crypto/KeyPair.swift b/SessionUtilitiesKit/Crypto/KeyPair.swift index b324ce94bc..a6a9e5d646 100644 --- a/SessionUtilitiesKit/Crypto/KeyPair.swift +++ b/SessionUtilitiesKit/Crypto/KeyPair.swift @@ -2,7 +2,7 @@ import Foundation -public struct KeyPair: Codable, Equatable { +public struct KeyPair: Sendable, Codable, Equatable, Hashable { public static let empty: KeyPair = KeyPair(publicKey: [], secretKey: []) public let publicKey: [UInt8] From af6385335363896f7b40d99d2cc4b7471f2f2110 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 31 Oct 2025 15:29:45 +1100 Subject: [PATCH 10/66] Updated libSession integration, plugged in some pro status observations --- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 32 +- .../Conversations/Input View/InputView.swift | 25 +- .../Settings/ThreadSettingsViewModel.swift | 47 +-- Session/Home/HomeVC.swift | 2 +- Session/Meta/AppDelegate.swift | 7 +- .../DeveloperSettingsProViewModel.swift | 213 ++++++++----- .../DeveloperSettingsViewModel.swift | 13 +- Session/Settings/SettingsViewModel.swift | 65 ++-- .../UIContextualAction+Utilities.swift | 6 +- .../Crypto/Crypto+LibSession.swift | 24 ++ .../Config Handling/LibSession+Pro.swift | 12 - .../MessageReceiver+VisibleMessages.swift | 7 +- .../MessageSender+Convenience.swift | 2 +- .../Utilities/ExtensionHelper.swift | 2 +- .../ProfilePictureView+Convenience.swift | 2 +- ...ProState.swift => SessionProManager.swift} | 4 +- .../SessionPro/SessionProAPI.swift | 3 +- .../SessionPro/Types/ProStatus.swift | 52 +++- .../NotificationServiceExtension.swift | 3 +- .../ShareNavController.swift | 5 +- .../Modals & Toast/ConfirmationModal.swift | 7 +- .../Components/ProfilePictureView.swift | 23 +- SessionUIKit/Components/RadioButton.swift | 46 ++- .../Components/SwiftUI/Modal+SwiftUI.swift | 4 +- .../Components/SwiftUI/ProCTAModal.swift | 286 ++++++++---------- .../Types/SessionProUIManagerType.swift | 82 +++++ .../Crypto/Crypto+SessionUtilitiesKit.swift | 8 +- .../Dependency Injection/Dependencies.swift | 129 +++++--- SessionUtilitiesKit/General/Feature.swift | 4 - .../Utilities/TypeConversion+Utilities.swift | 84 +++-- .../Types/CurrentValueAsyncStream.swift | 8 +- .../Utilities/AsyncSequence+Utilities.swift | 46 +++ .../AttachmentApprovalViewController.swift | 25 +- .../AttachmentTextToolbar.swift | 31 +- 35 files changed, 855 insertions(+), 466 deletions(-) rename SessionMessagingKit/Utilities/{SessionProState.swift => SessionProManager.swift} (97%) create mode 100644 SessionUIKit/Types/SessionProUIManagerType.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 44de83b80d..aebad35cc0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -196,7 +196,7 @@ 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; - 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProState.swift */; }; + 94B6BAF62E30A88800E718BB /* SessionProManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProManager.swift */; }; 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */; }; 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */; }; 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB012E3AE85800E718BB /* QRCode.swift */; }; @@ -965,6 +965,7 @@ FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */; }; + FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */; }; FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */; }; @@ -1612,7 +1613,7 @@ 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = PinnedConversationsCTA.webp; sourceTree = ""; }; 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTA.webp; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; - 94B6BAF52E30A88800E718BB /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + 94B6BAF52E30A88800E718BB /* SessionProManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManager.swift; sourceTree = ""; }; 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModal.swift; sourceTree = ""; }; 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; 94B6BB012E3AE85800E718BB /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; @@ -2254,6 +2255,7 @@ FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; + FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUIManagerType.swift; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupUrlInfo.swift; sourceTree = ""; }; @@ -3712,7 +3714,6 @@ C3BBE0B32554F0D30050F1E3 /* Utilities */ = { isa = PBXGroup; children = ( - 94B6BAF52E30A88800E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, @@ -3737,6 +3738,7 @@ FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, FDE754FF2C9BB0FA002A2623 /* SessionEnvironment.swift */, + 94B6BAF52E30A88800E718BB /* SessionProManager.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, FDE754FD2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift */, FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */, @@ -4717,6 +4719,7 @@ FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, + FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */, ); path = Types; sourceTree = ""; @@ -6401,6 +6404,7 @@ FD8A5B202DC03337004C689B /* AdaptiveText.swift in Sources */, 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */, FD71162A28DA83DF00B47552 /* GradientView.swift in Sources */, + FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */, 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, @@ -6861,7 +6865,7 @@ FDB5DAE02A95D84D002C8721 /* GroupUpdateMemberLeftMessage.swift in Sources */, FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, - 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */, + 94B6BAF62E30A88800E718BB /* SessionProManager.swift in Sources */, FDB5DADE2A95D847002C8721 /* GroupUpdatePromoteMessage.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index be3bb4451d..4d93cd4ba0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -558,7 +558,7 @@ extension ConversationVC: } @MainActor func handleCharacterLimitLabelTapped() { - guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + let didShowCTAModal: Bool = viewModel.dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in self?.hideInputAccessoryView() @@ -570,16 +570,15 @@ extension ConversationVC: presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard !didShowCTAModal else { return } self.hideInputAccessoryView() - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isCurrentUserSessionPro + let numberOfCharactersLeft: Int = viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) - let limit: Int = (viewModel.isCurrentUserSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) + let limit: Int = (viewModel.isCurrentUserSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -648,9 +647,8 @@ extension ConversationVC: // MARK: --Message Sending @MainActor func handleSendButtonTapped() { - guard LibSession.numberOfCharactersLeft( - for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isCurrentUserSessionPro + guard viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isCurrentUserSessionPro) return @@ -664,7 +662,7 @@ extension ConversationVC: } @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + let didShowCTAModal: Bool = viewModel.dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in self?.hideInputAccessoryView() @@ -676,9 +674,9 @@ extension ConversationVC: presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard !didShowCTAModal else { return } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -686,7 +684,7 @@ extension ConversationVC: title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) .localized(), scrollMode: .never ), @@ -1703,7 +1701,7 @@ extension ConversationVC: ) }, onProBadgeTapped: { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .generic, dismissType: .single, beforePresented: { [weak self] in diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 38fd63053b..c31e62e81c 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -13,11 +13,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private static let linkPreviewViewInset: CGFloat = 6 private static let thresholdForCharacterLimit: Int = 200 - private var disposables: Set = Set() private let dependencies: Dependencies private let threadVariant: SessionThread.Variant private weak var delegate: InputViewDelegate? - private var sessionProState: SessionProManagerType? + private var proObservationTask: Task? var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? @@ -190,7 +189,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .small) - result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + result.isHidden = dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro return result }() @@ -207,22 +206,19 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M self.dependencies = dependencies self.threadVariant = threadVariant self.delegate = delegate - self.sessionProState = dependencies[singleton: .sessionProState] super.init(frame: CGRect.zero) setUpViewHierarchy() - self.sessionProState?.isSessionProPublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] isPro in + proObservationTask = Task(priority: .userInitiated) { [weak self, sessionProUIManager = dependencies[singleton: .sessionProManager]] in + for await isPro in sessionProUIManager.currentUserIsPro { + await MainActor.run { self?.sessionProBadge.isHidden = isPro self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) } - ) - .store(in: &disposables) + } + } } override init(frame: CGRect) { @@ -235,6 +231,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M deinit { linkPreviewLoadTask?.cancel() + proObservationTask?.cancel() } private func setUpViewHierarchy() { @@ -326,10 +323,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } @MainActor func updateNumberOfCharactersLeft(_ text: String) { - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: dependencies[cache: .libSession].isSessionPro + let numberOfCharactersLeft: Int = dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: text.trimmingCharacters(in: .whitespacesAndNewlines) ) + characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0c9e9f599c..c39b8f8886 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -506,9 +506,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob label: "Pin Conversation" ), onTap: { [weak self] in - self?.toggleConversationPinnedStatus( - currentPinnedPriority: threadViewModel.threadPinnedPriority - ) + Task { + await self?.toggleConversationPinnedStatus( + currentPinnedPriority: threadViewModel.threadPinnedPriority + ) + } } ) ), @@ -1923,22 +1925,25 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } } - private func toggleConversationPinnedStatus(currentPinnedPriority: Int32) { + private func toggleConversationPinnedStatus(currentPinnedPriority: Int32) async { let isCurrentlyPinned: Bool = (currentPinnedPriority > LibSession.visiblePriority) + let isSessionPro: Bool = await dependencies[singleton: .sessionProManager] + .currentUserIsPro + .first(defaultValue: false) - if !isCurrentlyPinned && !dependencies[cache: .libSession].isSessionPro { + if !isCurrentlyPinned && !isSessionPro { // TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database - dependencies[singleton: .storage].writeAsync ( - updates: { [threadId, dependencies] db in + do { + let numPinnedConversations: Int = try await dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in let numPinnedConversations: Int = try SessionThread .filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority) .fetchCount(db) - guard numPinnedConversations < LibSession.PinnedConversationLimit else { + guard numPinnedConversations < SessionPro.PinnedConversationLimit else { return numPinnedConversations } - // We have the space to pin the conversation, so do so + /// We have the space to pin the conversation, so do so try SessionThread.updateVisibility( db, threadId: threadId, @@ -1948,30 +1953,30 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) return -1 - }, - completion: { [weak self, dependencies] result in - guard - let numPinnedConversations: Int = try? result.successOrThrow(), - numPinnedConversations > 0 - else { return } - + } + + /// If we already have too many conversations pinned then we need to show the CTA modal + guard numPinnedConversations > 0 else { return } + + await MainActor.run { [weak self, dependencies] in let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], variant: .morePinnedConvos( - isGrandfathered: (numPinnedConversations > LibSession.PinnedConversationLimit) + isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit) ), - dataManager: dependencies[singleton: .imageDataManager] + dataManager: dependencies[singleton: .imageDataManager], + sessionProUIManager: dependencies[singleton: .sessionProManager] ) ) self?.transitionToScreen(sessionProModal, transitionType: .present) } - ) + } + catch {} return } // If we are unpinning then no need to check the current count, just unpin immediately - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + try? await dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in try SessionThread.updateVisibility( db, threadId: threadId, diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 1cf6362d53..f13c12b304 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -375,7 +375,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // Onion request path countries cache Task.detached(priority: .background) { [dependencies = viewModel.dependencies] in - dependencies.warmCache(cache: .ip2Country) + dependencies.warm(cache: .ip2Country) } // Bind the UI to the view model diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 72d0ee2c58..5e9f424e14 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -57,7 +57,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies.set(singleton: .appContext, to: MainAppContext(using: dependencies)) verifyDBKeysAvailableBeforeBackgroundLaunch() - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) dependencies[singleton: .pushRegistrationManager].createVoipRegistryIfNecessary() // Prevent the device from sleeping during database view async registration @@ -79,7 +79,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Setup LibSession LibSession.setupLogger(using: dependencies) - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) + dependencies.warm(singleton: .network) // Configure the different targets SNUtilitiesKit.configure( @@ -805,7 +806,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // Navigate to the approriate screen depending on the onboarding state - dependencies.warmCache(cache: .onboarding) + dependencies.warm(cache: .onboarding) switch dependencies[cache: .onboarding].state { case .noUser, .noUserInvalidKeyPair, .noUserInvalidSeedGeneration: diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index c4960f8207..bab9a7b457 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -70,6 +70,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum TableItem: Hashable, Differentiable, CaseIterable { case enableSessionPro + case proStatus + case proIncomingMessages + case purchaseProSubscription case manageProSubscriptions case restoreProSubscription @@ -77,9 +80,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case submitPurchaseToProBackend case refreshProStatus - case proStatus - case proIncomingMessages - // MARK: - Conformance public typealias DifferenceIdentifier = String @@ -88,15 +88,15 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold switch self { case .enableSessionPro: return "enableSessionPro" + case .proStatus: return "proStatus" + case .proIncomingMessages: return "proIncomingMessages" + case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" case .requestRefund: return "requestRefund" case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" case .refreshProStatus: return "refreshProStatus" - - case .proStatus: return "proStatus" - case .proIncomingMessages: return "proIncomingMessages" } } @@ -109,15 +109,15 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold switch TableItem.enableSessionPro { case .enableSessionPro: result.append(.enableSessionPro); fallthrough + case .proStatus: result.append(.proStatus); fallthrough + case .proIncomingMessages: result.append(.proIncomingMessages); fallthrough + case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough - case .refreshProStatus: result.append(.refreshProStatus); fallthrough - - case .proStatus: result.append(.proStatus); fallthrough - case .proIncomingMessages: result.append(.proIncomingMessages) + case .refreshProStatus: result.append(.refreshProStatus) } return result @@ -136,6 +136,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public struct State: Equatable, ObservableKeyProvider { let sessionProEnabled: Bool + let mockCurrentUserSessionProStatus: Network.SessionPro.ProStatus? + let treatAllIncomingMessagesAsProMessages: Bool + let products: [Product] let purchasedProduct: Product? let purchaseError: String? @@ -151,9 +154,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let currentProStatus: String? let currentProStatusErrored: Bool - let mockCurrentUserSessionPro: Bool - let treatAllIncomingMessagesAsProMessages: Bool - @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( state: self, @@ -164,15 +164,18 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public let observedKeys: Set = [ .feature(.sessionProEnabled), - .updateScreen(DeveloperSettingsProViewModel.self), - .feature(.mockCurrentUserSessionPro), - .feature(.treatAllIncomingMessagesAsProMessages) + .feature(.mockCurrentUserSessionProStatus), + .feature(.treatAllIncomingMessagesAsProMessages), + .updateScreen(DeveloperSettingsProViewModel.self) ] static func initialState(using dependencies: Dependencies) -> State { return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProStatus: dependencies[feature: .mockCurrentUserSessionProStatus], + treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages], + products: [], purchasedProduct: nil, purchaseError: nil, @@ -186,10 +189,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold submittedTransactionErrored: false, currentProStatus: nil, - currentProStatusErrored: false, - - mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + currentProStatusErrored: false ) } } @@ -202,6 +202,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold isInitialQuery: Bool, using dependencies: Dependencies ) async -> State { + var currentProStatus: String? = previousState.currentProStatus + var currentProStatusErrored: Bool = previousState.currentProStatusErrored + var products: [Product] = previousState.products var purchasedProduct: Product? = previousState.purchasedProduct var purchaseError: String? = previousState.purchaseError @@ -212,8 +215,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var submittedTransactionRotatingKeyPair: KeyPair? = previousState.submittedTransactionRotatingKeyPair var submittedTransactionStatus: String? = previousState.submittedTransactionStatus var submittedTransactionErrored: Bool = previousState.submittedTransactionErrored - var currentProStatus: String? = previousState.currentProStatus - var currentProStatusErrored: Bool = previousState.currentProStatusErrored events.forEach { event in guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } @@ -243,6 +244,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProStatus: dependencies[feature: .mockCurrentUserSessionProStatus], + treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages], products: products, purchasedProduct: purchasedProduct, purchaseError: purchaseError, @@ -254,9 +257,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold submittedTransactionStatus: submittedTransactionStatus, submittedTransactionErrored: submittedTransactionErrored, currentProStatus: currentProStatus, - currentProStatusErrored: currentProStatusErrored, - mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + currentProStatusErrored: currentProStatusErrored ) } @@ -288,6 +289,54 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold guard state.sessionProEnabled else { return [general] } + // MARK: - Mockable Features + + let mockedProStatus: String = { + switch state.mockCurrentUserSessionProStatus { + case .some(let status): return "\(status)" + case .none: return "None" + } + }() + + let features: SectionModel = SectionModel( + model: .features, + elements: [ + SessionCell.Info( + id: .proStatus, + title: "Mocked Pro Status", + subtitle: """ + Force the current users Session Pro to a specific status locally. + + Current: \(mockedProStatus) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showMockProStatusModal(currentStatus: state.mockCurrentUserSessionProStatus) + } + ), + + SessionCell.Info( + id: .proIncomingMessages, + title: "All Pro Incoming Messages", + subtitle: """ + Treat all incoming messages as Pro messages. + """, + trailingAccessory: .toggle( + state.treatAllIncomingMessagesAsProMessages, + oldValue: previousState.treatAllIncomingMessagesAsProMessages + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .treatAllIncomingMessagesAsProMessages, + to: !state.treatAllIncomingMessagesAsProMessages + ) + } + ) + ] + ) + + // MARK: - Actual Pro Transactions and APIs + let purchaseStatus: String = { switch (state.purchaseError, state.purchaseStatus) { case (.some(let error), _): return "\(error)" @@ -338,6 +387,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Purchase Session Pro via the App Store. + Note: This only works on a real device (and some old iOS versions don't seem to support Sandbox accounts (eg. iOS 16). + Status: \(purchaseStatus) Product Name: \(productName) TransactionId: \(transactionId) @@ -417,47 +468,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ] ) - let features: SectionModel = SectionModel( - model: .features, - elements: [ - SessionCell.Info( - id: .proStatus, - title: "Pro Status", - subtitle: """ - Mock current user a Session Pro user locally. - """, - trailingAccessory: .toggle( - state.mockCurrentUserSessionPro, - oldValue: previousState.mockCurrentUserSessionPro - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .mockCurrentUserSessionPro, - to: !state.mockCurrentUserSessionPro - ) - } - ), - SessionCell.Info( - id: .proIncomingMessages, - title: "All Pro Incoming Messages", - subtitle: """ - Treat all incoming messages as Pro messages. - """, - trailingAccessory: .toggle( - state.treatAllIncomingMessagesAsProMessages, - oldValue: previousState.treatAllIncomingMessagesAsProMessages - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .treatAllIncomingMessagesAsProMessages, - to: !state.treatAllIncomingMessagesAsProMessages - ) - } - ) - ] - ) - - return [general, subscriptions, features] + return [general, features, subscriptions] } // MARK: - Functions @@ -465,7 +476,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public static func disableDeveloperMode(using dependencies: Dependencies) { let features: [FeatureConfig] = [ .sessionProEnabled, - .mockCurrentUserSessionPro, .treatAllIncomingMessagesAsProMessages ] @@ -474,13 +484,19 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: feature, to: nil) } + + if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { + dependencies.set(feature: .mockCurrentUserSessionProStatus, to: nil) + } } + // MARK: - Internal Functions + private func updateSessionProEnabled(current: Bool) { dependencies.set(feature: .sessionProEnabled, to: !current) - if dependencies.hasSet(feature: .mockCurrentUserSessionPro) { - dependencies.set(feature: .mockCurrentUserSessionPro, to: nil) + if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { + dependencies.set(feature: .mockCurrentUserSessionProStatus, to: nil) } if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { @@ -488,6 +504,65 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } + private func showMockProStatusModal(currentStatus: Network.SessionPro.ProStatus?) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Mocked Pro Status", + body: .radio( + explanation: ThemedAttributedString( + string: "Force the current users Session Pro to a specific status locally." + ), + warning: nil, + options: { + return ([nil] + Network.SessionPro.ProStatus.allCases).map { status in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: status.title, + descriptionText: status.subtitle.map { + ThemedAttributedString( + stringWithHTMLTags: $0, + font: RadioButton.descriptionFont + ) + }, + enabled: true, + selected: currentStatus == status + ) + } + }() + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [dependencies] modal in + let selectedStatus: Network.SessionPro.ProStatus? = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + let targetIndex: Int = (index - 1) + + guard targetIndex >= 0 && (targetIndex - 1) < Network.SessionPro.ProStatus.allCases.count else { + return nil + } + + return Network.SessionPro.ProStatus.allCases[targetIndex] + } + + default: return nil + } + }() + + dependencies.set(feature: .mockCurrentUserSessionProStatus, to: selectedStatus) + } + ) + ), + transitionType: .present + ) + } + + // MARK: - Pro Requests + private func purchaseSubscription() async { do { let products: [Product] = try await Product.products(for: ["com.getsession.org.pro_sub"]) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index b6b05ee1bb..427f5c02c3 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -311,6 +311,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) ] ) + + let sessionProStatus: String = (dependencies[feature: .sessionProEnabled] ? "Enabled" : "Disabled") + let mockedProStatus: String = { + switch (dependencies[feature: .sessionProEnabled], dependencies[feature: .mockCurrentUserSessionProStatus]) { + case (true, .some(let status)): return "\(status)" + case (false, _), (_, .none): return "None" + } + }() let sessionPro: SectionModel = SectionModel( model: .sessionPro, elements: [ @@ -320,7 +328,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, subtitle: """ Configure settings related to Session Pro. - Session Pro: \(dependencies[feature: .sessionProEnabled] ? "Enabled" : "Disabled") + Session Pro: \(sessionProStatus) + Mock Pro Status: \(mockedProStatus) """, trailingAccessory: .icon(.chevronRight), onTap: { [weak self, dependencies] in @@ -1115,7 +1124,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, dependencies.set(feature: .serviceNetwork, to: updatedNetwork) /// Start the new network cache and clear out the old one - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) /// Free the `oldNetworkCache` so it can be destroyed(the 'if' is only there to prevent the "variable never read" warning) if oldNetworkCache != nil { oldNetworkCache = nil } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 7a3e349321..117da5c686 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -8,6 +8,7 @@ import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit +import SessionNetworkingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -133,6 +134,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile + let sessionProStatus: Network.SessionPro.ProStatus? let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -145,6 +147,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public var observedKeys: Set { [ .profile(userSessionId.hexString), + .feature(.mockCurrentUserSessionProStatus), .feature(.serviceNetwork), .feature(.forceOffline), .setting(.developerModeEnabled), @@ -156,6 +159,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), + sessionProStatus: nil, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -189,6 +193,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile + var sessionProStatus: Network.SessionPro.ProStatus? = previousState.sessionProStatus var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -205,6 +210,11 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + /// If the device has a mock pro status set then use that + if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { + sessionProStatus = dependencies[feature: .mockCurrentUserSessionProStatus] + } + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading /// it then then there will be a diff in the `State` and the UI will update if @@ -255,6 +265,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return State( userSessionId: previousState.userSessionId, profile: profile, + sessionProStatus: sessionProStatus, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -294,7 +305,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl label: "Profile picture" ), onTap: { [weak viewModel] in - viewModel?.updateProfilePicture(currentUrl: state.profile.displayPictureUrl) + viewModel?.updateProfilePicture( + currentUrl: state.profile.displayPictureUrl, + sessionProStatus: state.sessionProStatus + ) } ), SessionCell.Info( @@ -666,7 +680,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) } - private func updateProfilePicture(currentUrl: String?) { + private func updateProfilePicture( + currentUrl: String?, + sessionProStatus: Network.SessionPro.ProStatus? + ) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false let currentSource: ImageDataManager.DataSource? = { @@ -717,16 +734,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ), dataManager: dependencies[singleton: .imageDataManager], onProBageTapped: { [weak self, dependencies] in - Task { @MainActor in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro - ), - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .animatedProfileImage(isSessionProActivated: (sessionProStatus == .active)), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) }, onClick: { [weak self] onDisplayPictureSelected in self?.onDisplayPictureSelected = { source, cropRect in @@ -767,25 +780,21 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch modal.info.body { case .image(.some(let source), _, _, let style, _, _, _, _, _): let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) + var didShowCTAModal: Bool = false - guard ( - !isAnimatedImage || - dependencies[cache: .libSession].isSessionPro || - !dependencies[feature: .sessionProEnabled] - ) else { - Task { @MainActor in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro - ), - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } - return + if isAnimatedImage && !dependencies[feature: .sessionProEnabled] { + didShowCTAModal = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .animatedProfileImage(isSessionProActivated: (sessionProStatus == .active)), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } + /// If we showed the CTA modal then the user doesn't have Session Pro so can't use the + /// selected image as their display picture + guard !didShowCTAModal else { return } + self?.updateProfile( displayPictureUpdateGenerator: { [weak self] in guard let self = self else { throw AttachmentError.uploadFailed } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index e9c932b671..d68db83017 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -234,15 +234,15 @@ public extension UIContextualAction { .filter(SessionThread.Columns.pinnedPriority > 0) .fetchCount(db) }), - pinnedConversationsNumber >= LibSession.PinnedConversationLimit + pinnedConversationsNumber >= SessionPro.PinnedConversationLimit { let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], variant: .morePinnedConvos( - isGrandfathered: (pinnedConversationsNumber > LibSession.PinnedConversationLimit) + isGrandfathered: (pinnedConversationsNumber > SessionPro.PinnedConversationLimit) ), dataManager: dependencies[singleton: .imageDataManager], + sessionProUIManager: dependencies[singleton: .sessionProManager], afterClosed: { [completionHandler] in completionHandler(true) } diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 3ac7f13f49..15605e5e52 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -379,6 +379,30 @@ public extension Crypto.Verification { } } +// MARK: - Session Pro + +public extension Crypto.Generator { + static func sessionProMasterKeyPair() -> Crypto.Generator { + return Crypto.Generator( + id: "encodedMessage", + args: [] + ) { dependencies in + let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + var cMasterSecretKey: [UInt8] = [UInt8](repeating: 0, count: 256) + + guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } + + guard session_ed25519_pro_privkey_for_ed25519_seed(cEd25519SecretKey, &cMasterSecretKey) else { + throw CryptoError.keyGenerationFailed + } + + let seed: Data = try dependencies[singleton: .crypto].tryGenerate(.ed25519Seed(ed25519SecretKey: cMasterSecretKey)) + + return try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair(seed: seed)) + } + } +} + extension bytes32: CAccessible & CMutable {} extension bytes33: CAccessible & CMutable {} extension bytes64: CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 31ba81eb19..82af56c8e9 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -5,18 +5,6 @@ import GRDB import SessionUtil import SessionUtilitiesKit -// MARK: - Character Limits - -public extension LibSession { - static var CharacterLimit: Int { 2000 } - static var ProCharacterLimit: Int { 10000 } - static var PinnedConversationLimit: Int { 5 } - - static func numberOfCharactersLeft(for content: String, isSessionPro: Bool) -> Int { - return ((isSessionPro ? ProCharacterLimit : CharacterLimit) - content.utf16.count) - } -} - // MARK: - Session Pro // TODO: Implementation diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 0cd008f19f..8150711946 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -694,9 +694,10 @@ extension MessageReceiver { let utf16View = text.utf16 // TODO: Remove after Session Pro is enabled let isSessionProEnabled: Bool = (dependencies.hasSet(feature: .sessionProEnabled) && dependencies[feature: .sessionProEnabled]) - let offset: Int = (isSessionProEnabled && !isProMessage) ? - LibSession.CharacterLimit : - LibSession.ProCharacterLimit + let offset: Int = (isSessionProEnabled && !isProMessage ? + SessionPro.CharacterLimit : + SessionPro.ProCharacterLimit + ) guard utf16View.count > offset else { return text } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f0ea676964..70531f1871 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -429,7 +429,7 @@ extension MessageSender { public extension VisibleMessage { static func from(_ db: ObservingDatabase, interaction: Interaction, proProof: String? = nil) -> VisibleMessage { let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) - let shouldAttachProProof: Bool = ((interaction.body ?? "").utf16.count > LibSession.CharacterLimit) + let shouldAttachProProof: Bool = ((interaction.body ?? "").utf16.count > SessionPro.CharacterLimit) let visibleMessage: VisibleMessage = VisibleMessage( sender: interaction.authorId, diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 4c2d2897a3..a207a342a6 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -942,7 +942,7 @@ public class ExtensionHelper: ExtensionHelperType { @discardableResult public func waitUntilMessagesAreLoaded(timeout: DispatchTimeInterval) async -> Bool { return await withThrowingTaskGroup(of: Bool.self) { [weak self] group in group.addTask { - guard await self?.messagesLoadedStream.currentValue != true else { return true } + guard await self?.messagesLoadedStream.getCurrent() != true else { return true } _ = await self?.messagesLoadedStream.stream.first { $0 == true } return true } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 1806635be0..9aabfcdff0 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -200,7 +200,7 @@ public extension ProfilePictureView { case .none: return .generic(false) case .some(let profile) where profile.id == dependencies[cache: .general].sessionId.hexString: - return .currentUser(dependencies[singleton: .sessionProState]) + return .currentUser(dependencies[singleton: .sessionProManager]) case .some(let profile): return .contact(dependencies.mutate(cache: .libSession, { $0.validateProProof(for: profile) })) diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProManager.swift similarity index 97% rename from SessionMessagingKit/Utilities/SessionProState.swift rename to SessionMessagingKit/Utilities/SessionProManager.swift index 17e0ea1b7e..6ac2458c99 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProManager.swift @@ -1,8 +1,10 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +import Foundation +import SessionUtil import SessionUIKit +import SessionNetworkingKit import SessionUtilitiesKit -import Combine // MARK: - Singleton diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 0fee0aee0f..0b237d35b6 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -95,7 +95,6 @@ public extension Network.SessionPro { static func getProStatus( includeHistory: Bool = false, - page: UInt32 = 0, masterKeyPair: KeyPair, using dependencies: Dependencies ) throws -> Network.PreparedRequest { @@ -107,7 +106,7 @@ public extension Network.SessionPro { cMasterPrivateKey, cMasterPrivateKey.count, timestampMs, - page + includeHistory ) ) diff --git a/SessionNetworkingKit/SessionPro/Types/ProStatus.swift b/SessionNetworkingKit/SessionPro/Types/ProStatus.swift index 7fd1c8ed16..8641f75f6c 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProStatus.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProStatus.swift @@ -1,10 +1,13 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation import SessionUtil +import SessionUtilitiesKit public extension Network.SessionPro { - enum ProStatus: CaseIterable, CustomStringConvertible { + enum ProStatus: Sendable, CaseIterable, CustomStringConvertible { case neverBeenPro case active case expired @@ -35,3 +38,50 @@ public extension Network.SessionPro { } } } + +// MARK: - FeatureStorage + +public extension FeatureStorage { + static let mockCurrentUserSessionProStatus: FeatureConfig = Dependencies.create( + identifier: "mockCurrentUserSessionProStatus" + ) +} + +// MARK: - Router + +extension Optional: @retroactive RawRepresentable, @retroactive FeatureOption where Wrapped == Network.SessionPro.ProStatus { + public typealias RawValue = Int + + public var rawValue: Int { + switch self { + case .none: return -1 + case .neverBeenPro: return 1 + case .active: return 2 + case .expired: return 3 + } + } + + public init?(rawValue: Int) { + switch rawValue { + case 1: self = .neverBeenPro + case 2: self = .active + case 3: self = .expired + default: self = nil + } + } + + // MARK: - Feature Option + + public static var defaultOption: Network.SessionPro.ProStatus? = nil + + public var title: String { (self.map { "\($0)" } ?? "None") } + + public var subtitle: String? { + switch self { + case .none: return "Use the current users actual status." + case .neverBeenPro: return "The user has never had Session Pro before." + case .active: return "The user has an active Session Pro subscription." + case .expired: return "The user's Session Pro subscription has expired." + } + } +} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index e0e4db3f1e..91c9318a4f 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -100,7 +100,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } /// Setup Version Info and Network - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) + dependencies.warm(singleton: .sessionProManager) /// Configure the different targets SNUtilitiesKit.configure( diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index f1dae8efd7..82bbaadb3f 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -43,7 +43,7 @@ final class ShareNavController: UINavigationController { guard !SNUtilitiesKit.isRunningTests else { return } - dependencies.warmCache(cache: .appVersion) + dependencies.warm(cache: .appVersion) AppSetup.setupEnvironment( appSpecificBlock: { [dependencies] in @@ -60,7 +60,8 @@ final class ShareNavController: UINavigationController { // stringlint:ignore_stop // Setup LibSession - dependencies.warmCache(cache: .libSessionNetwork) + dependencies.warm(cache: .libSessionNetwork) + dependencies.warm(singleton: .sessionProManager) // Configure the different targets SNUtilitiesKit.configure( diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 66663822e7..549a8665ef 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -521,6 +521,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { options: options.enumerated().map { otherIndex, otherInfo in Info.Body.RadioOptionInfo( title: otherInfo.title, + descriptionText: otherInfo.descriptionText, enabled: otherInfo.enabled, selected: (index == otherIndex), accessibility: otherInfo.accessibility @@ -531,6 +532,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { ) } radioButton.text = optionInfo.title + radioButton.descriptionText = optionInfo.descriptionText radioButton.accessibilityLabel = optionInfo.accessibility?.label radioButton.accessibilityIdentifier = optionInfo.accessibility?.identifier radioButton.update(isEnabled: optionInfo.enabled, isSelected: optionInfo.selected) @@ -1033,17 +1035,20 @@ public extension ConfirmationModal.Info { public struct RadioOptionInfo: Equatable, Hashable { public let title: String + public let descriptionText: ThemedAttributedString? public let enabled: Bool public let selected: Bool public let accessibility: Accessibility? public init( title: String, + descriptionText: ThemedAttributedString? = nil, enabled: Bool, selected: Bool = false, accessibility: Accessibility? = nil ) { self.title = title + self.descriptionText = descriptionText self.enabled = enabled self.selected = selected self.accessibility = accessibility @@ -1083,7 +1088,7 @@ public extension ConfirmationModal.Info { description: NSAttributedString?, accessibility: Accessibility?, dataManager: ImageDataManagerType, - onProBageTapped: (() -> Void)?, + onProBageTapped: (@MainActor () -> Void)?, onClick: (@MainActor (@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void) ) diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index d1beb7ff9e..88871f0400 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -9,7 +9,7 @@ public final class ProfilePictureView: UIView { public enum AnimationBehaviour { case generic(Bool) // For communities and when Pro is not enabled case contact(Bool) - case currentUser(SessionProManagerType) + case currentUser(SessionProUIManagerType) } let source: ImageDataManager.DataSource? @@ -116,7 +116,7 @@ public final class ProfilePictureView: UIView { } private var dataManager: ImageDataManagerType? - private var disposables: Set = Set() + private var proObservationTask: Task? public var size: Size { didSet { widthConstraint.constant = (customWidth ?? size.viewSize) @@ -451,7 +451,8 @@ public final class ProfilePictureView: UIView { private func prepareForReuse() { /// Reset the disposables in case this was called with different data/ - disposables = Set() + proObservationTask?.cancel() + proObservationTask = nil imageView.image = nil imageView.shouldAnimateImage = false @@ -667,17 +668,15 @@ public final class ProfilePictureView: UIView { case .generic(let enableAnimation), .contact(let enableAnimation): targetImageView.shouldAnimateImage = enableAnimation - case .currentUser(let currentUserSessionProState): - targetImageView.shouldAnimateImage = currentUserSessionProState.isSessionProSubject.value - currentUserSessionProState.isSessionProPublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak targetImageView] isPro in + case .currentUser(let sessionProUIManager): + proObservationTask?.cancel() + proObservationTask = Task(priority: .userInitiated) { [weak targetImageView] in + for await isPro in sessionProUIManager.currentUserIsPro { + await MainActor.run { targetImageView?.shouldAnimateImage = isPro } - ) - .store(in: &disposables) + } + } } } } diff --git a/SessionUIKit/Components/RadioButton.swift b/SessionUIKit/Components/RadioButton.swift index 6eb78fdb80..e3d6d7ff7e 100644 --- a/SessionUIKit/Components/RadioButton.swift +++ b/SessionUIKit/Components/RadioButton.swift @@ -4,6 +4,7 @@ import UIKit // FIXME: Remove this and use the 'SessionCell' instead public class RadioButton: UIView { + public static let descriptionFont: UIFont = .systemFont(ofSize: Values.verySmallFontSize) private static let selectionBorderSize: CGFloat = 26 private static let selectionSize: CGFloat = 20 @@ -36,6 +37,14 @@ public class RadioButton: UIView { set { titleLabel.text = newValue } } + public var descriptionText: ThemedAttributedString? { + get { descriptionLabel.attributedText.map { ThemedAttributedString(attributedString: $0) } } + set { + descriptionLabel.themeAttributedText = newValue + descriptionLabel.isHidden = (newValue == nil) + } + } + public private(set) var isEnabled: Bool = true public private(set) var isSelected: Bool = false private let titleTextColor: ThemeValue @@ -51,6 +60,16 @@ public class RadioButton: UIView { return result }() + private lazy var textStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.isUserInteractionEnabled = false + result.axis = .vertical + result.distribution = .fill + + return result + }() + private lazy var titleLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -62,6 +81,18 @@ public class RadioButton: UIView { return result }() + private lazy var descriptionLabel: UILabel = { + let result: UILabel = UILabel() + result.translatesAutoresizingMaskIntoConstraints = false + result.isUserInteractionEnabled = false + result.font = RadioButton.descriptionFont + result.themeTextColor = titleTextColor + result.numberOfLines = 0 + result.isHidden = true + + return result + }() + private let selectionBorderView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false @@ -108,10 +139,13 @@ public class RadioButton: UIView { private func setupViewHierarchy(size: Size) { addSubview(selectionButton) - addSubview(titleLabel) + addSubview(textStackView) addSubview(selectionBorderView) addSubview(selectionView) + textStackView.addArrangedSubview(titleLabel) + textStackView.addArrangedSubview(descriptionLabel) + self.heightAnchor.constraint( greaterThanOrEqualTo: titleLabel.heightAnchor, constant: Values.mediumSpacing @@ -123,9 +157,9 @@ public class RadioButton: UIView { selectionButton.pin(to: self) - titleLabel.center(.vertical, in: self) - titleLabel.pin(.leading, to: .leading, of: self) - titleLabel.pin(.trailing, to: .trailing, of: selectionBorderView, withInset: -Values.verySmallSpacing) + textStackView.center(.vertical, in: self) + textStackView.pin(.leading, to: .leading, of: self) + textStackView.pin(.trailing, to: .leading, of: selectionBorderView, withInset: -Values.verySmallSpacing) selectionBorderView.center(.vertical, in: self) selectionBorderView.pin(.trailing, to: .trailing, of: self) @@ -153,21 +187,25 @@ public class RadioButton: UIView { switch (self.isEnabled, self.isSelected) { case (true, true): titleLabel.themeTextColor = titleTextColor + descriptionLabel.themeTextColor = titleTextColor selectionBorderView.themeBorderColor = .radioButton_selectedBorder selectionView.themeBackgroundColor = .radioButton_selectedBackground case (true, false): titleLabel.themeTextColor = titleTextColor + descriptionLabel.themeTextColor = titleTextColor selectionBorderView.themeBorderColor = .radioButton_unselectedBorder selectionView.themeBackgroundColor = .radioButton_unselectedBackground case (false, true): titleLabel.themeTextColor = .disabled + descriptionLabel.themeTextColor = .disabled selectionBorderView.themeBorderColor = .radioButton_disabledBorder selectionView.themeBackgroundColor = .radioButton_disabledSelectedBackground case (false, false): titleLabel.themeTextColor = .disabled + descriptionLabel.themeTextColor = .disabled selectionBorderView.themeBorderColor = .radioButton_disabledBorder selectionView.themeBackgroundColor = .radioButton_disabledUnselectedBackground } diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index 2fa2c8ee75..0ed048d982 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -6,7 +6,7 @@ public struct Modal_SwiftUI: View where Content: View { let host: HostWrapper let dismissType: Modal.DismissType let afterClosed: (() -> Void)? - let content: (@escaping ((() -> Void)?) -> Void) -> Content + let content: (@escaping @MainActor ((() -> Void)?) -> Void) -> Content let cornerRadius: CGFloat = 11 let shadowRadius: CGFloat = 10 @@ -62,7 +62,7 @@ public struct Modal_SwiftUI: View where Content: View { // MARK: - Dismiss Logic - private func close(_ internalAfterClosed: (() -> Void)? = nil) { + @MainActor private func close(_ internalAfterClosed: (() -> Void)? = nil) { // Recursively dismiss all modals (ie. find the first modal presented by a non-modal // and get that to dismiss it's presented view controller) var targetViewController: UIViewController? = host.controller diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 6b4ed4802c..600e3400ee 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -11,135 +11,30 @@ public struct ProCTAModal: View { case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) case groupLimit(isAdmin: Bool) - - // stringlint:ignore_contents - public var backgroundImageName: String { - switch self { - case .generic: - return "GenericCTA.webp" - case .longerMessages: - return "HigherCharLimitCTA.webp" - case .animatedProfileImage: - return "AnimatedProfileCTA.webp" - case .morePinnedConvos: - return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin): - return isAdmin ? "" : "" - } - } - // stringlint:ignore_contents - public var animatedAvatarImageURL: URL? { - switch self { - case .generic, .animatedProfileImage: - return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") - default: return nil - } - } - /// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the - /// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size - /// of the modal. - public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { - switch self { - case .generic: - return (1313.5, 753) - case .animatedProfileImage: - return (690, 363) - default: return (0, 0) - } - } - - public var subtitle: String { - switch self { - case .generic: - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localized() - case .longerMessages: - return "proCallToActionLongerMessages" - .put(key: "app_pro", value: Constants.app_pro) - .localized() - case .animatedProfileImage(let isSessionProActivated): - return isSessionProActivated ? - "proAnimatedDisplayPicture".localized() : - "proAnimatedDisplayPictureCallToActionDescription" - .put(key: "app_pro", value: Constants.app_pro) - .localized() - case .morePinnedConvos(let isGrandfathered): - return isGrandfathered ? - "proCallToActionPinnedConversations" - .put(key: "app_pro", value: Constants.app_pro) - .localized() : - "proCallToActionPinnedConversationsMoreThan" - .put(key: "app_pro", value: Constants.app_pro) - .localized() - case .groupLimit: - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localized() - } - } - - public var benefits: [String] { - switch self { - case .generic: - return [ - "proFeatureListLargerGroups".localized(), - "proFeatureListLongerMessages".localized(), - "proFeatureListLoadsMore".localized() - ] - case .longerMessages: - return [ - "proFeatureListLongerMessages".localized(), - "proFeatureListLargerGroups".localized(), - "proFeatureListLoadsMore".localized() - ] - case .animatedProfileImage: - return [ - "proFeatureListAnimatedDisplayPicture".localized(), - "proFeatureListLargerGroups".localized(), - "proFeatureListLoadsMore".localized() - ] - case .morePinnedConvos: - return [ - "proFeatureListPinnedConversations".localized(), - "proFeatureListLargerGroups".localized(), - "proFeatureListLoadsMore".localized() - ] - case .groupLimit(let isAdmin): - return !isAdmin ? [] : - [ - "proFeatureListLargerGroups".localized(), - "proFeatureListLongerMessages".localized(), - "proFeatureListLoadsMore".localized() - ] - } - } } @EnvironmentObject var host: HostWrapper @State var proCTAImageHeight: CGFloat = 0 - private var delegate: SessionProManagerType? private let variant: ProCTAModal.Variant - private var dataManager: ImageDataManagerType + private let dataManager: ImageDataManagerType + private let sessionProUIManager: SessionProUIManagerType let dismissType: Modal.DismissType let afterClosed: (() -> Void)? let afterUpgrade: (() -> Void)? public init( - delegate: SessionProManagerType?, variant: ProCTAModal.Variant, dataManager: ImageDataManagerType, + sessionProUIManager: SessionProUIManagerType, dismissType: Modal.DismissType = .recursive, afterClosed: (() -> Void)? = nil, afterUpgrade: (() -> Void)? = nil ) { - self.delegate = delegate self.variant = variant self.dataManager = dataManager + self.sessionProUIManager = sessionProUIManager self.dismissType = dismissType self.afterClosed = afterClosed self.afterUpgrade = afterUpgrade @@ -315,11 +210,14 @@ public struct ProCTAModal: View { HStack(spacing: Values.smallSpacing) { // Upgrade Button ShineButton { - delegate?.upgradeToPro { result in - if result { - afterUpgrade?() + Task { + await sessionProUIManager.upgradeToPro { result in + if result { + afterUpgrade?() + } + + Task { @MainActor in close(nil) } } - close(nil) } } label: { Text("theContinue".localized()) @@ -361,60 +259,136 @@ public struct ProCTAModal: View { } } -// MARK: - SessionProManagerType +// MARK: - Variant Content -public protocol SessionProManagerType: AnyObject { - var isSessionProSubject: CurrentValueSubject { get } - var isSessionProPublisher: AnyPublisher { get } - func upgradeToPro(completion: ((_ result: Bool) -> Void)?) - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool -} +public extension ProCTAModal.Variant { + // stringlint:ignore_contents + var backgroundImageName: String { + switch self { + case .generic: + return "GenericCTA.webp" + case .longerMessages: + return "HigherCharLimitCTA.webp" + case .animatedProfileImage: + return "AnimatedProfileCTA.webp" + case .morePinnedConvos: + return "PinnedConversationsCTA.webp" + case .groupLimit(let isAdmin): + return isAdmin ? "" : "" + } + } + + // stringlint:ignore_contents + var animatedAvatarImageURL: URL? { + switch self { + case .generic, .animatedProfileImage: + return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") + default: return nil + } + } + + /// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the + /// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size + /// of the modal. + var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { + switch self { + case .generic: + return (1313.5, 753) + case .animatedProfileImage: + return (690, 363) + default: return (0, 0) + } + } -// MARK: - Convenience -public extension SessionProManagerType { - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - beforePresented: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: beforePresented, - afterClosed: afterClosed, - presenting: presenting - ) + var subtitle: String { + switch self { + case .generic: + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localized() + + case .longerMessages: + return "proCallToActionLongerMessages" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + + case .animatedProfileImage(let isSessionProActivated): + return isSessionProActivated ? + "proAnimatedDisplayPicture".localized() : + "proAnimatedDisplayPictureCallToActionDescription" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + + case .morePinnedConvos(let isGrandfathered): + return isGrandfathered ? + "proCallToActionPinnedConversations" + .put(key: "app_pro", value: Constants.app_pro) + .localized() : + "proCallToActionPinnedConversationsMoreThan" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + + case .groupLimit: + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localized() + } } - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - beforePresented: nil, - afterClosed: nil, - presenting: presenting - ) + var benefits: [String] { + switch self { + case .generic: + return [ + "proFeatureListLargerGroups".localized(), + "proFeatureListLongerMessages".localized(), + "proFeatureListLoadsMore".localized() + ] + + case .longerMessages: + return [ + "proFeatureListLongerMessages".localized(), + "proFeatureListLargerGroups".localized(), + "proFeatureListLoadsMore".localized() + ] + + case .animatedProfileImage: + return [ + "proFeatureListAnimatedDisplayPicture".localized(), + "proFeatureListLargerGroups".localized(), + "proFeatureListLoadsMore".localized() + ] + + case .morePinnedConvos: + return [ + "proFeatureListPinnedConversations".localized(), + "proFeatureListLargerGroups".localized(), + "proFeatureListLoadsMore".localized() + ] + + case .groupLimit(let isAdmin): + guard isAdmin else { return [] } + + return [ + "proFeatureListLargerGroups".localized(), + "proFeatureListLongerMessages".localized(), + "proFeatureListLoadsMore".localized() + ] + } } } +// MARK: - Previews + struct ProCTAModal_Previews: PreviewProvider { static var previews: some View { Group { PreviewThemeWrapper(theme: .classicDark) { ProCTAModal( - delegate: nil, variant: .generic, dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(isPro: false), dismissType: .single, afterClosed: nil ) @@ -424,9 +398,9 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .classicLight) { ProCTAModal( - delegate: nil, variant: .generic, dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(isPro: false), dismissType: .single, afterClosed: nil ) @@ -436,9 +410,9 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .oceanDark) { ProCTAModal( - delegate: nil, variant: .generic, dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(isPro: false), dismissType: .single, afterClosed: nil ) @@ -448,9 +422,9 @@ struct ProCTAModal_Previews: PreviewProvider { PreviewThemeWrapper(theme: .oceanLight) { ProCTAModal( - delegate: nil, variant: .generic, dataManager: ImageDataManager(), + sessionProUIManager: NoopSessionProUIManager(isPro: false), dismissType: .single, afterClosed: nil ) diff --git a/SessionUIKit/Types/SessionProUIManagerType.swift b/SessionUIKit/Types/SessionProUIManagerType.swift new file mode 100644 index 0000000000..c2def1b4a1 --- /dev/null +++ b/SessionUIKit/Types/SessionProUIManagerType.swift @@ -0,0 +1,82 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public protocol SessionProUIManagerType: Actor { + nonisolated var currentUserIsCurrentlyPro: Bool { get } + nonisolated var currentUserIsPro: AsyncStream { get } + + nonisolated func numberOfCharactersLeft(for content: String) -> Int + func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool +} + +// MARK: - Convenience + +public extension SessionProUIManagerType { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: beforePresented, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: nil, + afterClosed: nil, + presenting: presenting + ) + } +} + +// MARK: - Noop + +internal actor NoopSessionProUIManager: SessionProUIManagerType { + private let isPro: Bool + nonisolated public let currentUserIsCurrentlyPro: Bool + nonisolated public var currentUserIsPro: AsyncStream { + AsyncStream(unfolding: { return self.isPro }) + } + + init(isPro: Bool) { + self.isPro = isPro + self.currentUserIsCurrentlyPro = isPro + } + + nonisolated public func numberOfCharactersLeft(for content: String) -> Int { 0 } + + public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { + completion?(false) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + return false + } +} diff --git a/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift b/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift index a6df9b00ff..525d6f149b 100644 --- a/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift +++ b/SessionUtilitiesKit/Crypto/Crypto+SessionUtilitiesKit.swift @@ -126,9 +126,9 @@ public extension Crypto.Generator { } } - static func ed25519KeyPair(seed: [UInt8]) -> Crypto.Generator { + static func ed25519KeyPair(seed: I) -> Crypto.Generator { return Crypto.Generator(id: "ed25519KeyPair_Seed", args: [seed]) { - var cSeed: [UInt8] = seed + var cSeed: [UInt8] = Array(seed) var pubkey: [UInt8] = [UInt8](repeating: 0, count: 32) var seckey: [UInt8] = [UInt8](repeating: 0, count: 64) @@ -141,9 +141,9 @@ public extension Crypto.Generator { } } - static func ed25519Seed(ed25519SecretKey: [UInt8]) -> Crypto.Generator { + static func ed25519Seed(ed25519SecretKey: I) -> Crypto.Generator { return Crypto.Generator(id: "ed25519Seed", args: [ed25519SecretKey]) { - var cEd25519SecretKey: [UInt8] = ed25519SecretKey + var cEd25519SecretKey: [UInt8] = Array(ed25519SecretKey) var seed: [UInt8] = [UInt8](repeating: 0, count: 32) guard diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 8308765f4e..e28d02e3f0 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -13,7 +13,7 @@ public class Dependencies { @ThreadSafeObject private static var cachedIsRTLRetriever: (requiresMainThread: Bool, retriever: () -> Bool) = (false, { false }) @ThreadSafeObject private var storage: DependencyStorage = DependencyStorage() - private typealias DependencyChange = (Dependencies.DependencyStorage.Key, DependencyStorage.Value?) + private typealias DependencyChange = (Dependencies.Key, DependencyStorage.Value?) private let dependencyChangeStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() // MARK: - Subscript Access @@ -118,12 +118,16 @@ public class Dependencies { // MARK: - Instance management public func has(singleton: SingletonConfig) -> Bool { - let key: DependencyStorage.Key = DependencyStorage.Key.Variant.singleton.key(singleton.identifier) + let key: Dependencies.Key = Key.Variant.singleton.key(singleton.identifier) return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) } - public func warmCache(cache: CacheConfig) { + public func warm(singleton: SingletonConfig) { + _ = getOrCreate(singleton) + } + + public func warm(cache: CacheConfig) { _ = getOrCreate(cache) } @@ -144,7 +148,7 @@ public class Dependencies { _cachedIsRTLRetriever.set(to: (requiresMainThread, isRTLRetriever)) } - private func waitUntilInitialised(targetKey: Dependencies.DependencyStorage.Key) async throws { + private func waitUntilInitialised(targetKey: Dependencies.Key) async throws { /// If we already have an instance (which isn't a `NoopDependency`) then no need to observe the stream guard !_storage.performMap({ $0.instances[targetKey]?.isNoop == false }) else { return } @@ -157,11 +161,11 @@ public class Dependencies { } public func waitUntilInitialised(singleton: SingletonConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.singleton.key(singleton.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.singleton.key(singleton.identifier)) } public func waitUntilInitialised(cache: CacheConfig) async throws { - try await waitUntilInitialised(targetKey: DependencyStorage.Key.Variant.cache.key(cache.identifier)) + try await waitUntilInitialised(targetKey: Key.Variant.cache.key(cache.identifier)) } } @@ -180,8 +184,7 @@ private extension ThreadSafeObject { public extension Dependencies { func hasSet(feature: FeatureConfig) -> Bool { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Use a `readLock` to check if a value has been set guard @@ -193,8 +196,7 @@ public extension Dependencies { } func set(feature: FeatureConfig, to updatedFeature: T?) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) let typedValue: DependencyStorage.Value? = _storage.performMap { $0.instances[key] } /// Update the cached & in-memory values @@ -213,8 +215,7 @@ public extension Dependencies { } func reset(feature: FeatureConfig) { - let key: Dependencies.DependencyStorage.Key = DependencyStorage.Key.Variant.feature - .key(feature.identifier) + let key: Dependencies.Key = Key.Variant.feature.key(feature.identifier) /// Reset the cached and in-memory values _storage.perform { storage in @@ -225,8 +226,6 @@ public extension Dependencies { removeValue(feature.identifier, of: .feature) /// Notify observers - - Task { await dependencyChangeStream.send((key, nil)) } notifyAsync(events: [ ObservedEvent(key: .feature(feature), value: nil), ObservedEvent(key: .featureGroup(feature), value: nil) @@ -246,33 +245,35 @@ public enum DependenciesError: Error { // MARK: - Storage Management +public extension Dependencies { + struct Key: Hashable, CustomStringConvertible { + public enum Variant: String { + case singleton + case cache + case userDefaults + case feature + + public func key(_ identifier: String) -> Key { + return Key(identifier, of: self) + } + } + + public let identifier: String + public let variant: Variant + public var description: String { "\(variant): \(identifier)" } + + fileprivate init(_ identifier: String, of variant: Variant) { + self.identifier = identifier + self.variant = variant + } + } +} + private extension Dependencies { class DependencyStorage { var initializationLocks: [Key: NSLock] = [:] var instances: [Key: Value] = [:] - struct Key: Hashable, CustomStringConvertible { - enum Variant: String { - case singleton - case cache - case userDefaults - case feature - - func key(_ identifier: String) -> Key { - return Key(identifier, of: self) - } - } - - let identifier: String - let variant: Variant - var description: String { "\(variant): \(identifier)" } - - init(_ identifier: String, of variant: Variant) { - self.identifier = identifier - self.variant = variant - } - } - enum Value { case singleton(Any) case cache(ThreadSafeObject) @@ -344,7 +345,7 @@ private extension Dependencies { identifier: String, constructor: DependencyStorage.Constructor ) -> Value { - let key: Dependencies.DependencyStorage.Key = constructor.variant.key(identifier) + let key: Dependencies.Key = constructor.variant.key(identifier) /// If we already have an instance then just return that (need to get a `writeLock` here because accessing values on a class /// isn't thread safe so we need to block during access) @@ -384,7 +385,7 @@ private extension Dependencies { /// Convenience method to store a dependency instance in memory in a thread-safe way @discardableResult private func setValue(_ value: T, typedStorage: DependencyStorage.Value, key: String) -> T { - let finalKey: DependencyStorage.Key = typedStorage.distinctKey(for: key) + let finalKey: Key = typedStorage.distinctKey(for: key) let result: T = _storage.performUpdateAndMap { storage in storage.instances[finalKey] = typedStorage return (storage, value) @@ -400,8 +401,8 @@ private extension Dependencies { } /// Convenience method to remove a dependency instance from memory in a thread-safe way - private func removeValue(_ key: String, of variant: DependencyStorage.Key.Variant) { - let finalKey: DependencyStorage.Key = variant.key(key) + private func removeValue(_ key: String, of variant: Key.Variant) { + let finalKey: Key = variant.key(key) _storage.performUpdate { storage in storage.instances.removeValue(forKey: finalKey) return storage @@ -415,7 +416,7 @@ private extension Dependencies { private extension Dependencies.DependencyStorage { struct Constructor { - let variant: Key.Variant + let variant: Dependencies.Key.Variant let create: () -> (typedStorage: Dependencies.DependencyStorage.Value, value: T) static func singleton(_ constructor: @escaping () -> T) -> Constructor { @@ -451,3 +452,49 @@ private extension Dependencies.DependencyStorage { } } } + +// MARK: - Async/Await + +public extension Dependencies { + /// This function builds without issue on iOS 15 but unfortunately it ends up crashing due to the incomplete async/await implementation + /// that was included in that version. Everything works without issues (or crashes) on iOS 16 and above though hence the `@available` + /// restrictions in place + @available(iOS 16.0, *) + private func stream(key: Dependencies.Key, initialValueRetriever: (@escaping () -> T?)) -> AsyncStream { + return dependencyChangeStream.stream + .filter { changedKey, _ in changedKey == key } + .compactMap { _, changedValue in changedValue?.value(as: T.self) } + .prepend(initialValueRetriever()) + .asAsyncStream() + } + + @available(iOS 16.0, *) + func stream(key: Dependencies.Key, of type: T.Type) -> AsyncStream { + return stream(key: key, initialValueRetriever: { nil }) + } + + @available(iOS 16.0, *) + func stream(singleton: SingletonConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.singleton.key(singleton.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[singleton: singleton] }) + } + + @available(iOS 16.0, *) + func stream(cache: CacheConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.cache.key(cache.identifier) + + return stream(key: key, initialValueRetriever: { [weak self] in self?[cache: cache] }) + } + + @available(iOS 16.0, *) + func stream(feature: FeatureConfig) -> AsyncStream { + let key = Dependencies.Key.Variant.feature.key(feature.identifier) + + return dependencyChangeStream.stream + .filter { changedKey, _ in changedKey == key } + .compactMap { [weak self] _, _ in self?[feature: feature] } + .prepend(self[feature: feature]) + .asAsyncStream() + } +} diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 7500f0dca4..aba9c2b702 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -90,10 +90,6 @@ public extension FeatureStorage { identifier: "sessionPro" ) - static let mockCurrentUserSessionPro: FeatureConfig = Dependencies.create( - identifier: "mockCurrentUserSessionPro" - ) - static let treatAllIncomingMessagesAsProMessages: FeatureConfig = Dependencies.create( identifier: "treatAllIncomingMessagesAsProMessages" ) diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index 4dbfae899b..b591548c98 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -166,22 +166,30 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } + } + + func get(_ keyPath: KeyPath) -> String { + withUnsafePointer(to: self) { $0.get(keyPath) } } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -337,22 +345,30 @@ public extension ReadablePointer { func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } func get(_ keyPath: KeyPath) -> String { getCString(keyPath) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { - getCString(keyPath, nullIfEmpty: nullIfEmpty) + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) } - func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false, explicitLength: Int? = nil) -> String? { + getCString(keyPath, nullIfEmpty: nullIfEmpty, explicitLength: explicitLength) + } + + func get(_ keyPath: KeyPath) -> String { + getCString(keyPath) + } + + func get(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { getCString(keyPath, nullIfEmpty: nullIfEmpty) } @@ -476,12 +492,17 @@ private extension ReadablePointer { return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) } - func _string(from value: T) -> String { + func _string(from value: T, explicitLength: Int? = nil) -> String { withUnsafeBytes(of: value) { rawBufferPointer in guard let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return "" } + if let length: Int = explicitLength { + return (String(pointer: buffer, length: length) ?? "") + } + + /// If we weren't given an explicit length then assume the string is null-terminated return String(cString: buffer) } } @@ -506,8 +527,21 @@ private extension ReadablePointer { return _string(from: ptr[keyPath: keyPath]) } - func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { - let result: String = _string(from: ptr[keyPath: keyPath]) + func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool, explicitLength: Int?) -> String? { + let result: String = _string(from: ptr[keyPath: keyPath], explicitLength: explicitLength) + + return (!nullIfEmpty || !result.isEmpty ? result : nil) + } + + func getCString(_ keyPath: KeyPath) -> String { + let stringPtr: string8 = ptr[keyPath: keyPath] + + return (String(pointer: stringPtr.data, length: stringPtr.size) ?? "") + } + + func getCString(_ keyPath: KeyPath, nullIfEmpty: Bool) -> String? { + let stringPtr: string8 = ptr[keyPath: keyPath] + let result: String = (String(pointer: stringPtr.data, length: stringPtr.size) ?? "") return (!nullIfEmpty || !result.isEmpty ? result : nil) } diff --git a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift index b2f9f13e1a..0be805aa3a 100644 --- a/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift +++ b/SessionUtilitiesKit/Types/CurrentValueAsyncStream.swift @@ -6,7 +6,7 @@ public actor CurrentValueAsyncStream: CancellationAwareStream private let lifecycleManager: StreamLifecycleManager = StreamLifecycleManager() /// This is the most recently emitted value - public private(set) var currentValue: Element + private var currentValue: Element // MARK: - Initialization @@ -15,7 +15,11 @@ public actor CurrentValueAsyncStream: CancellationAwareStream } // MARK: - Functions - + + public func getCurrent() async -> Element { + return currentValue + } + public func send(_ newValue: Element) async { currentValue = newValue lifecycleManager.send(newValue) diff --git a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift index 3992824965..69ca0d77ff 100644 --- a/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AsyncSequence+Utilities.swift @@ -1,5 +1,51 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +import Foundation + +public extension AsyncSequence { + func asAsyncStream() -> AsyncStream { + AsyncStream { continuation in + let task: Task = Task { + for try await element in self { + continuation.yield(element) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + /// Returns a new async sequence that emits the given initial element before emitting the elements from the upstream sequence + func prepend(_ initialElement: Element?) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + if let initialElement { + continuation.yield(initialElement) + } + + let observationTask: Task = Task { + do { + for try await element in self { + continuation.yield(element) + } + } + catch { + continuation.finish(throwing: error) + } + + continuation.finish() + } + + continuation.onTermination = { @Sendable _ in + observationTask.cancel() + } + } + } +} + public extension AsyncSequence where Element: Equatable { func removeDuplicates() -> AsyncThrowingStream { return AsyncThrowingStream { continuation in diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 2cbef2e093..e548a40b47 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -651,7 +651,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + let didShowCTAModal: Bool = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in self?.hideInputAccessoryView() @@ -663,9 +663,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard didShowCTAModal else { return } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -673,7 +673,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) .localized(), scrollMode: .never ), @@ -692,7 +692,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { - guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + let didShowCTAModal: Bool = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in self?.hideInputAccessoryView() @@ -704,9 +704,9 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { presenting: { [weak self] modal in self?.present(modal, animated: true) } - ) else { - return - } + ) + + guard didShowCTAModal else { return } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -714,7 +714,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit)) + .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) .localized(), scrollMode: .never ), @@ -731,9 +731,8 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { @MainActor func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) { guard let text = attachmentTextToolbar.text, - LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: isSessionPro + dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { showModalForMessagesExceedingCharacterLimit(isSessionPro: isSessionPro) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 0f9a2c430c..efd3f0c722 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -3,6 +3,7 @@ import Foundation import UIKit import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit import Combine @@ -23,10 +24,9 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { // MARK: - Variables - private var disposables: Set = Set() public weak var delegate: AttachmentTextToolbarDelegate? private let dependencies: Dependencies - private var sessionProState: SessionProManagerType? + private var proObservationTask: Task? var text: String? { get { inputTextView.text } @@ -91,7 +91,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .small) - result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + result.isHidden = dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro return result }() @@ -101,28 +101,29 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { init(delegate: AttachmentTextToolbarDelegate, using dependencies: Dependencies) { self.dependencies = dependencies self.delegate = delegate - self.sessionProState = dependencies[singleton: .sessionProState] super.init(frame: CGRect.zero) setUpViewHierarchy() - self.sessionProState?.isSessionProPublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] isPro in + proObservationTask = Task(priority: .userInitiated) { [weak self, sessionProUIManager = dependencies[singleton: .sessionProManager]] in + for await isPro in sessionProUIManager.currentUserIsPro { + await MainActor.run { self?.sessionProBadge.isHidden = isPro self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) } - ) - .store(in: &disposables) + } + } } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + proObservationTask?.cancel() + } + private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight @@ -164,11 +165,11 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { proStackView.center(.horizontal, in: sendButton) } - func updateNumberOfCharactersLeft(_ text: String) { - let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( - for: text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: dependencies[cache: .libSession].isSessionPro + @MainActor func updateNumberOfCharactersLeft(_ text: String) { + let numberOfCharactersLeft: Int = dependencies[singleton: .sessionProManager].numberOfCharactersLeft( + for: text.trimmingCharacters(in: .whitespacesAndNewlines) ) + characterLimitLabel.text = "\(numberOfCharactersLeft.formatted(format: .abbreviated(decimalPlaces: 1)))" characterLimitLabel.themeTextColor = (numberOfCharactersLeft < 0) ? .danger : .textPrimary proStackView.alpha = (numberOfCharactersLeft <= Self.thresholdForCharacterLimit) ? 1 : 0 From aef37ee5fc08f188101c4ee93a299f72f7361bfa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 31 Oct 2025 17:23:44 +1100 Subject: [PATCH 11/66] Started plugging in some logic to use proper pro status and proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Updated the code to use the libSession function to determine message lengths • Fixed an issue where CTA modal benefits could incorrectly be truncated --- Session.xcodeproj/project.pbxproj | 50 +++- .../ConversationVC+Interaction.swift | 2 +- .../Conversations/ConversationViewModel.swift | 2 +- .../DeveloperSettingsProViewModel.swift | 32 +-- .../DeveloperSettingsViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 48 ++-- .../UIContextualAction+Utilities.swift | 2 +- .../Jobs/ReuploadUserDisplayPictureJob.swift | 4 +- .../Config Handling/LibSession+Contacts.swift | 2 +- .../Config Handling/LibSession+Pro.swift | 11 - .../LibSession+UserProfile.swift | 2 +- .../LibSession+SessionMessagingKit.swift | 3 - .../MessageReceiver+VisibleMessages.swift | 3 +- .../Sending & Receiving/MessageReceiver.swift | 3 +- .../MessageSender+Convenience.swift | 10 +- .../SessionPro/SessionProManager.swift | 225 ++++++++++++++++++ .../SessionProDecodedProForMessage.swift | 33 +++ .../Types/SessionProExtraFeatures.swift | 30 +++ .../Types/SessionProFeatureStatus.swift | 29 +++ .../SessionPro/Types/SessionProFeatures.swift | 31 +++ .../Types/SessionProFeaturesForMessage.swift | 34 +++ .../SessionPro/Types/SessionProStatus.swift | 38 +++ .../Utilities/DisplayPictureManager.swift | 12 +- .../Utilities/SessionProManager.swift | 64 ----- .../SessionPro/SessionPro.swift | 2 +- ...tatus.swift => BackendUserProStatus.swift} | 10 +- .../SessionPro/Types/ProProof.swift | 16 ++ .../Components/SwiftUI/ProCTAModal.swift | 1 + .../AttachmentApprovalViewController.swift | 7 +- 29 files changed, 551 insertions(+), 157 deletions(-) create mode 100644 SessionMessagingKit/SessionPro/SessionProManager.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProStatus.swift delete mode 100644 SessionMessagingKit/Utilities/SessionProManager.swift rename SessionNetworkingKit/SessionPro/Types/{ProStatus.swift => BackendUserProStatus.swift} (85%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index aebad35cc0..03280ee526 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -600,7 +600,7 @@ FD306BD02EB02F3900ADB003 /* GetProStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */; }; FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD12EB031AB00ADB003 /* PaymentItem.swift */; }; FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */; }; - FD306BD62EB0323000ADB003 /* ProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* ProStatus.swift */; }; + FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */; }; FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD72EB033CB00ADB003 /* Plan.swift */; }; FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */; }; FD306BDC2EB0436C00ADB003 /* GetProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */; }; @@ -966,6 +966,12 @@ FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */; }; FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */; }; + FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */; }; + FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */; }; + FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProFeatures.swift */; }; + FDAA36CC2EB47D7D0040603E /* SessionProExtraFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */; }; + FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */; }; + FDAA36D02EB485F20040603E /* SessionProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */; }; FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */; }; @@ -1986,7 +1992,7 @@ FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProStatusResponse.swift; sourceTree = ""; }; FD306BD12EB031AB00ADB003 /* PaymentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentItem.swift; sourceTree = ""; }; FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatus.swift; sourceTree = ""; }; - FD306BD52EB0322E00ADB003 /* ProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProStatus.swift; sourceTree = ""; }; + FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendUserProStatus.swift; sourceTree = ""; }; FD306BD72EB033CB00ADB003 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderMetadata.swift; sourceTree = ""; }; FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProProofRequest.swift; sourceTree = ""; }; @@ -2256,6 +2262,12 @@ FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUIManagerType.swift; sourceTree = ""; }; + FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeaturesForMessage.swift; sourceTree = ""; }; + FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatureStatus.swift; sourceTree = ""; }; + FDAA36C92EB476060040603E /* SessionProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatures.swift; sourceTree = ""; }; + FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExtraFeatures.swift; sourceTree = ""; }; + FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedProForMessage.swift; sourceTree = ""; }; + FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProStatus.swift; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupUrlInfo.swift; sourceTree = ""; }; @@ -3738,7 +3750,6 @@ FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, FDE754FF2C9BB0FA002A2623 /* SessionEnvironment.swift */, - 94B6BAF52E30A88800E718BB /* SessionProManager.swift */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, FDE754FD2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift */, FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */, @@ -3830,6 +3841,7 @@ C3A721332558BDDF0043A11F /* Open Groups */, C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* LibSession */, + FDAA36C32EB4740E0040603E /* SessionPro */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, FD245C612850664300B966DD /* Configuration.swift */, @@ -4107,7 +4119,7 @@ FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */, FD306BD72EB033CB00ADB003 /* Plan.swift */, FD0F85762EA83D8F004E0B98 /* ProProof.swift */, - FD306BD52EB0322E00ADB003 /* ProStatus.swift */, + FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */, FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */, FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */, FD306BCD2EB02E3400ADB003 /* Signature.swift */, @@ -5021,6 +5033,28 @@ path = Models; sourceTree = ""; }; + FDAA36C32EB4740E0040603E /* SessionPro */ = { + isa = PBXGroup; + children = ( + FDAA36C42EB474B50040603E /* Types */, + 94B6BAF52E30A88800E718BB /* SessionProManager.swift */, + ); + path = SessionPro; + sourceTree = ""; + }; + FDAA36C42EB474B50040603E /* Types */ = { + isa = PBXGroup; + children = ( + FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, + FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */, + FDAA36C92EB476060040603E /* SessionProFeatures.swift */, + FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, + FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, + FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */, + ); + path = Types; + sourceTree = ""; + }; FDB5DAD22A9483D4002C8721 /* Group Update Messages */ = { isa = PBXGroup; children = ( @@ -6535,7 +6569,7 @@ FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */, FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, - FD306BD62EB0323000ADB003 /* ProStatus.swift in Sources */, + FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */, FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, @@ -6787,6 +6821,7 @@ 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, + FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, @@ -6796,6 +6831,7 @@ FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, FDE33BBE2D5C3AF100E56F42 /* _037_GroupsExpiredFlag.swift in Sources */, FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */, + FDAA36CC2EB47D7D0040603E /* SessionProExtraFeatures.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, FD2272FD2C352D8E004D8A6C /* LibSession+ConvoInfoVolatile.swift in Sources */, FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, @@ -6822,6 +6858,7 @@ FDB5DAC72A9447E7002C8721 /* _036_GroupsRebuildChanges.swift in Sources */, FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */, 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */, + FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, @@ -6902,6 +6939,7 @@ FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */, + FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, @@ -6933,6 +6971,7 @@ FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, FD2272762C32911C004D8A6C /* ExpirationUpdateJob.swift in Sources */, + FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, @@ -6966,6 +7005,7 @@ FDB5DADA2A95D839002C8721 /* GroupUpdateInfoChangeMessage.swift in Sources */, FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */, FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */, + FDAA36D02EB485F20040603E /* SessionProStatus.swift in Sources */, FDD23AED2E4590A10057E853 /* _041_RenameTableSettingToKeyValueStore.swift in Sources */, FDE754FE2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4d93cd4ba0..fbab8245e0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -684,7 +684,7 @@ extension ConversationVC: title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 75549db6f3..5c06eb6c2b 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -72,7 +72,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private var markAsReadPublisher: AnyPublisher? public let dependencies: Dependencies - public var isCurrentUserSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } + public var isCurrentUserSessionPro: Bool { dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro } public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index bab9a7b457..f188c5e7f4 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -136,7 +136,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public struct State: Equatable, ObservableKeyProvider { let sessionProEnabled: Bool - let mockCurrentUserSessionProStatus: Network.SessionPro.ProStatus? + let mockCurrentUserSessionProBackendStatus: Network.SessionPro.BackendUserProStatus? let treatAllIncomingMessagesAsProMessages: Bool let products: [Product] @@ -164,7 +164,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public let observedKeys: Set = [ .feature(.sessionProEnabled), - .feature(.mockCurrentUserSessionProStatus), + .feature(.mockCurrentUserSessionProBackendStatus), .feature(.treatAllIncomingMessagesAsProMessages), .updateScreen(DeveloperSettingsProViewModel.self) ] @@ -173,7 +173,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], - mockCurrentUserSessionProStatus: dependencies[feature: .mockCurrentUserSessionProStatus], + mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages], products: [], @@ -244,7 +244,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], - mockCurrentUserSessionProStatus: dependencies[feature: .mockCurrentUserSessionProStatus], + mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages], products: products, purchasedProduct: purchasedProduct, @@ -292,7 +292,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold // MARK: - Mockable Features let mockedProStatus: String = { - switch state.mockCurrentUserSessionProStatus { + switch state.mockCurrentUserSessionProBackendStatus { case .some(let status): return "\(status)" case .none: return "None" } @@ -311,7 +311,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold """, trailingAccessory: .icon(.squarePen), onTap: { [weak viewModel] in - viewModel?.showMockProStatusModal(currentStatus: state.mockCurrentUserSessionProStatus) + viewModel?.showMockProStatusModal(currentStatus: state.mockCurrentUserSessionProBackendStatus) } ), @@ -485,8 +485,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: feature, to: nil) } - if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { - dependencies.set(feature: .mockCurrentUserSessionProStatus, to: nil) + if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: nil) } } @@ -495,8 +495,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func updateSessionProEnabled(current: Bool) { dependencies.set(feature: .sessionProEnabled, to: !current) - if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { - dependencies.set(feature: .mockCurrentUserSessionProStatus, to: nil) + if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: nil) } if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { @@ -504,7 +504,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } - private func showMockProStatusModal(currentStatus: Network.SessionPro.ProStatus?) { + private func showMockProStatusModal(currentStatus: Network.SessionPro.BackendUserProStatus?) { self.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( @@ -515,7 +515,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ), warning: nil, options: { - return ([nil] + Network.SessionPro.ProStatus.allCases).map { status in + return ([nil] + Network.SessionPro.BackendUserProStatus.allCases).map { status in ConfirmationModal.Info.Body.RadioOptionInfo( title: status.title, descriptionText: status.subtitle.map { @@ -533,7 +533,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold confirmTitle: "select".localized(), cancelStyle: .alert_text, onConfirm: { [dependencies] modal in - let selectedStatus: Network.SessionPro.ProStatus? = { + let selectedStatus: Network.SessionPro.BackendUserProStatus? = { switch modal.info.body { case .radio(_, _, let options): return options @@ -542,18 +542,18 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .map { index, _ in let targetIndex: Int = (index - 1) - guard targetIndex >= 0 && (targetIndex - 1) < Network.SessionPro.ProStatus.allCases.count else { + guard targetIndex >= 0 && (targetIndex - 1) < Network.SessionPro.BackendUserProStatus.allCases.count else { return nil } - return Network.SessionPro.ProStatus.allCases[targetIndex] + return Network.SessionPro.BackendUserProStatus.allCases[targetIndex] } default: return nil } }() - dependencies.set(feature: .mockCurrentUserSessionProStatus, to: selectedStatus) + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: selectedStatus) } ) ), diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 427f5c02c3..360e5e1400 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -314,7 +314,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let sessionProStatus: String = (dependencies[feature: .sessionProEnabled] ? "Enabled" : "Disabled") let mockedProStatus: String = { - switch (dependencies[feature: .sessionProEnabled], dependencies[feature: .mockCurrentUserSessionProStatus]) { + switch (dependencies[feature: .sessionProEnabled], dependencies[feature: .mockCurrentUserSessionProBackendStatus]) { case (true, .some(let status)): return "\(status)" case (false, _), (_, .none): return "None" } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 117da5c686..b13eaf5641 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -147,7 +147,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public var observedKeys: Set { [ .profile(userSessionId.hexString), - .feature(.mockCurrentUserSessionProStatus), + .feature(.mockCurrentUserSessionProBackendStatus), .feature(.serviceNetwork), .feature(.forceOffline), .setting(.developerModeEnabled), @@ -211,8 +211,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } /// If the device has a mock pro status set then use that - if dependencies.hasSet(feature: .mockCurrentUserSessionProStatus) { - sessionProStatus = dependencies[feature: .mockCurrentUserSessionProStatus] + if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { + sessionProStatus = dependencies[feature: .mockCurrentUserSessionProBackendStatus] } /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading @@ -709,24 +709,28 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { - guard dependencies[feature: .sessionProEnabled] else { return nil } - return dependencies[cache: .libSession].isSessionPro ? - "proAnimatedDisplayPictureModalDescription" - .localized() - .addProBadge( - at: .leading, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small - ): - "proAnimatedDisplayPicturesNonProModalDescription" - .localized() - .addProBadge( - at: .trailing, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small - ) + switch (dependencies[feature: .sessionProEnabled], sessionProStatus) { + case (false, _): return nil + case (true, .active): + return "proAnimatedDisplayPictureModalDescription" + .localized() + .addProBadge( + at: .leading, + font: .systemFont(ofSize: Values.smallFontSize), + textColor: .textSecondary, + proBadgeSize: .small + ) + + case (true, _): + return "proAnimatedDisplayPicturesNonProModalDescription" + .localized() + .addProBadge( + at: .trailing, + font: .systemFont(ofSize: Values.smallFontSize), + textColor: .textSecondary, + proBadgeSize: .small + ) + } }(), accessibility: Accessibility( identifier: "Upload", @@ -866,7 +870,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: false ) } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index d68db83017..c06ec2dd89 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -228,7 +228,7 @@ public extension UIContextualAction { tableView: tableView ) { _, _, completionHandler in if !isCurrentlyPinned, - !dependencies[cache: .libSession].isSessionPro, + !dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro, let pinnedConversationsNumber: Int = dependencies[singleton: .storage].read({ db in try SessionThread .filter(SessionThread.Columns.pinnedPriority > 0) diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 32608ed6e7..48edf8df2c 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -113,7 +113,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: displayPictureUrl.absoluteString, key: displayPictureEncryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: true ), using: dependencies @@ -173,7 +173,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: true ), using: dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index ebb82f02d3..402670193e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -72,7 +72,7 @@ internal extension LibSessionCacheType { return .contactUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - contactProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented + sessionProProof: getProProof() // TODO: [PRO] double check if this is needed after Pro Proof is implemented ) }(), nicknameUpdate: .set(to: data.profile.nickname), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 82af56c8e9..d2de25df53 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -9,10 +9,6 @@ import SessionUtilitiesKit // TODO: Implementation public extension LibSessionCacheType { - var isSessionPro: Bool { - guard dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .mockCurrentUserSessionPro] - } func validateProProof(for message: Message?) -> Bool { guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } @@ -23,11 +19,4 @@ public extension LibSessionCacheType { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } return dependencies[feature: .treatAllIncomingMessagesAsProMessages] } - - func getProProof() -> String? { - guard isSessionPro else { - return nil - } - return "" - } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 94a9b3d35b..1bf181b52e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -56,7 +56,7 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - sessionProProof: getProProof(), // TODO: double check if this is needed after Pro Proof is implemented + sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: false ) }(), diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 0d0838c1cd..4dd9e6be6b 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -969,7 +969,6 @@ public extension LibSession { public protocol LibSessionImmutableCacheType: ImmutableCacheType { var userSessionId: SessionId { get } var isEmpty: Bool { get } - var isSessionPro: Bool { get } var allDumpSessionIds: Set { get } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool @@ -981,7 +980,6 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT var dependencies: Dependencies { get } var userSessionId: SessionId { get } var isEmpty: Bool { get } - var isSessionPro: Bool { get } var allDumpSessionIds: Set { get } // MARK: - State Management @@ -1246,7 +1244,6 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { let dependencies: Dependencies let userSessionId: SessionId = .invalid let isEmpty: Bool = true - var isSessionPro: Bool = false let allDumpSessionIds: Set = [] init(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 8150711946..645377e4bf 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -693,8 +693,7 @@ extension MessageReceiver { let utf16View = text.utf16 // TODO: Remove after Session Pro is enabled - let isSessionProEnabled: Bool = (dependencies.hasSet(feature: .sessionProEnabled) && dependencies[feature: .sessionProEnabled]) - let offset: Int = (isSessionProEnabled && !isProMessage ? + let offset: Int = (dependencies[feature: .sessionProEnabled] && !isProMessage ? SessionPro.CharacterLimit : SessionPro.ProCharacterLimit ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 3610800b16..9934b7c2c3 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -65,7 +65,7 @@ public enum MessageReceiver { } /// For all other cases we can just decode the message - let (proto, sender, sentTimestampMs): (SNProtoContent, String, UInt64) = try dependencies[singleton: .crypto].tryGenerate( + let (proto, sender, sentTimestampMs, decodedProForMessage): (SNProtoContent, String, UInt64) = try dependencies[singleton: .crypto].tryGenerate( .decodedMessage( encodedMessage: data, origin: origin @@ -173,6 +173,7 @@ public enum MessageReceiver { threadVariant: threadVariant, serverExpirationTimestamp: serverExpirationTimestamp, proto: proto + // TODO: [PRO] Store the pro proof in these details ), uniqueIdentifier: uniqueIdentifier ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 70531f1871..0e092218f7 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -25,11 +25,7 @@ extension MessageSender { send( db, - message: VisibleMessage.from( - db, - interaction: interaction, - proProof: dependencies.mutate(cache: .libSession, { $0.getProProof() }) - ), + message: VisibleMessage.from(db, interaction: interaction), threadId: threadId, interactionId: interactionId, to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), @@ -427,9 +423,8 @@ extension MessageSender { // MARK: - Database Type Conversion public extension VisibleMessage { - static func from(_ db: ObservingDatabase, interaction: Interaction, proProof: String? = nil) -> VisibleMessage { + static func from(_ db: ObservingDatabase, interaction: Interaction) -> VisibleMessage { let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) - let shouldAttachProProof: Bool = ((interaction.body ?? "").utf16.count > SessionPro.CharacterLimit) let visibleMessage: VisibleMessage = VisibleMessage( sender: interaction.authorId, @@ -460,7 +455,6 @@ public extension VisibleMessage { expiresInSeconds: interaction.expiresInSeconds, expiresStartedAtMs: interaction.expiresStartedAtMs ) - .with(proProof: (shouldAttachProProof ? proProof : nil)) return visibleMessage } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift new file mode 100644 index 0000000000..85308f6d8d --- /dev/null +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -0,0 +1,225 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let sessionProManager: SingletonConfig = Dependencies.create( + identifier: "sessionProManager", + createInstance: { dependencies in SessionProManager(using: dependencies) } + ) +} + +// MARK: - SessionPro + +public enum SessionPro { + public static var CharacterLimit: Int { SESSION_PROTOCOL_PRO_STANDARD_CHARACTER_LIMIT } + public static var ProCharacterLimit: Int { SESSION_PROTOCOL_PRO_HIGHER_CHARACTER_LIMIT } + public static var PinnedConversationLimit: Int { SESSION_PROTOCOL_PRO_STANDARD_PINNED_CONVERSATION_LIMIT } +} + +// MARK: - SessionProManager + +public actor SessionProManager: SessionProManagerType { + private let dependencies: Dependencies + nonisolated private let syncState: SessionProManagerSyncState + private var proStatusObservationTask: Task? + private var masterKeyPair: KeyPair? + private var rotatingKeyPair: KeyPair? + + nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + + nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.backendUserProStatus == .active } + nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } + nonisolated public var currentUserIsPro: AsyncStream { + backendUserProStatusStream.stream + .map { $0 == .active } + .asAsyncStream() + } + nonisolated public var characterLimit: Int { + (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) + } + + nonisolated public var backendUserProStatus: AsyncStream { + backendUserProStatusStream.stream + } + nonisolated public var proProof: AsyncStream { proProofStream.stream } + + // MARK: - Initialization + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.syncState = SessionProManagerSyncState(using: dependencies) + self.masterKeyPair = dependencies[singleton: .crypto].generate(.sessionProMasterKeyPair()) + + Task { await startProStatusObservations() } + } + + deinit { + proStatusObservationTask?.cancel() + } + + // MARK: - Functions + + nonisolated public func numberOfCharactersLeft(for content: String) -> Int { + let features: SessionPro.FeaturesForMessage = features(for: content) + + switch features.status { + case .utfDecodingError: + /// If we got a decoding error then fallback + Log.error(.sessionPro, "Failed to decode content length due to error: \(features.error ?? "Unknown error")") + return (characterLimit - content.utf16.count) + + case .success, .exceedsCharacterLimit: return (characterLimit - features.codePointCount) + } + } + + nonisolated public func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage { + guard let cMessage: [CChar] = message.cString(using: .utf8) else { + return SessionPro.FeaturesForMessage.invalidString + } + + return SessionPro.FeaturesForMessage( + session_protocol_pro_features_for_utf8( + cMessage, + (cMessage.count - 1), /// Need to `- 1` to avoid counting the null-termination character + extraFeatures.libSessionValue + ) + ) + } + + public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .active) + await backendUserProStatusStream.send(.active) + completion?(true) + } + + @discardableResult @MainActor public func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + guard + syncState.dependencies[feature: .sessionProEnabled], + syncState.backendUserProStatus != .active + else { return false } + + beforePresented?() + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + variant: variant, + dataManager: syncState.dependencies[singleton: .imageDataManager], + sessionProUIManager: self, + dismissType: dismissType, + afterClosed: afterClosed + ) + ) + presenting?(sessionProModal) + + return true + } + + // MARK: - Internal Functions + + private func startProStatusObservations() { + proStatusObservationTask?.cancel() + proStatusObservationTask = Task { + await withTaskGroup(of: Void.self) { [weak self, dependencies] group in + if #available(iOS 16, *) { + /// Observe the main Session Pro feature flag + group.addTask { + for await proEnabled in dependencies.stream(feature: .sessionProEnabled) { + guard proEnabled else { + self?.syncState.update(backendUserProStatus: .set(to: nil)) + await self?.backendUserProStatusStream.send(.none) + continue + } + + } + } + + /// Observe the explicit mocking for the current session pro status + group.addTask { + for await status in dependencies.stream(feature: .mockCurrentUserSessionProBackendStatus) { + /// Ignore status updates if pro is enabled, and if the mock status was removed we need to fetch + /// the "real" status + guard dependencies[feature: .sessionProEnabled] else { continue } + guard let status: Network.SessionPro.BackendUserProStatus = status else { + self?.syncState.update(backendUserProStatus: .set(to: nil)) + await self?.backendUserProStatusStream.send(nil) + continue + } + + self?.syncState.update(backendUserProStatus: .set(to: status)) + await self?.backendUserProStatusStream.send(status) + } + } + } + + /// If Session Pro isn't enabled then no need to do any of the other tasks (they check the proper Session Pro stauts + /// via `libSession` and the network + guard dependencies[feature: .sessionProEnabled] else { + await group.waitForAll() + return + } + + await group.waitForAll() + } + } + } +} + +// MARK: - SyncState + +private final class SessionProManagerSyncState { + private let lock: NSLock = NSLock() + private let _dependencies: Dependencies + private var _backendUserProStatus: Network.SessionPro.BackendUserProStatus? = nil + private var _proProof: Network.SessionPro.ProProof? = nil + + fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } + fileprivate var backendUserProStatus: Network.SessionPro.BackendUserProStatus? { + lock.withLock { _backendUserProStatus } + } + fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } + + fileprivate init(using dependencies: Dependencies) { + self._dependencies = dependencies + } + + fileprivate func update( + backendUserProStatus: Update = .useExisting, + proProof: Update = .useExisting + ) { + lock.withLock { + self._backendUserProStatus = backendUserProStatus.or(self._backendUserProStatus) + self._proProof = proProof.or(self._proProof) + } + } +} + +// MARK: - SessionProManagerType + +public protocol SessionProManagerType: SessionProUIManagerType { + nonisolated var characterLimit: Int { get } + nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } + + nonisolated var backendUserProStatus: AsyncStream { get } + nonisolated var proProof: AsyncStream { get } + + nonisolated func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage +} + +public extension SessionProManagerType { + nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage { + return features(for: message, extraFeatures: .none) + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift new file mode 100644 index 0000000000..359def4778 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -0,0 +1,33 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionNetworkingKit + +public extension SessionPro { + struct DecodedProForMessage: Equatable { + let status: SessionPro.ProStatus + let proProof: Network.SessionPro.ProProof + let features: Features + + public static let none: DecodedProForMessage = DecodedProForMessage( + status: .none, + proProof: Network.SessionPro.ProProof(), + features: .none + ) + + // MARK: - Initialization + + init(status: SessionPro.ProStatus, proProof: Network.SessionPro.ProProof, features: Features) { + self.status = status + self.proProof = proProof + self.features = features + } + + init(_ libSessionValue: session_protocol_decoded_pro) { + status = SessionPro.ProStatus(libSessionValue.status) + proProof = Network.SessionPro.ProProof(libSessionValue.proof) + features = Features(libSessionValue.features) + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift new file mode 100644 index 0000000000..cd731cc239 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift @@ -0,0 +1,30 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct ExtraFeatures: OptionSet, Equatable, Hashable { + public let rawValue: UInt64 + + public static let none: ExtraFeatures = ExtraFeatures(rawValue: 0) + public static let proBadge: ExtraFeatures = ExtraFeatures(rawValue: 1 << 0) + public static let animatedAvatar: ExtraFeatures = ExtraFeatures(rawValue: 1 << 1) + public static let all: ExtraFeatures = [ proBadge, animatedAvatar ] + + var libSessionValue: SESSION_PROTOCOL_PRO_EXTRA_FEATURES { + SESSION_PROTOCOL_PRO_EXTRA_FEATURES(rawValue) + } + + // MARK: - Initialization + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + init(_ libSessionValue: SESSION_PROTOCOL_PRO_EXTRA_FEATURES) { + self = ExtraFeatures(rawValue: libSessionValue) + } + } +} + diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift new file mode 100644 index 0000000000..55867939fe --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatureStatus.swift @@ -0,0 +1,29 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + enum FeatureStatus: Equatable { + case success + case utfDecodingError + case exceedsCharacterLimit + + var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS { + switch self { + case .success: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS + case .utfDecodingError: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR + case .exceedsCharacterLimit: return SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT + } + } + + init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS) { + switch libSessionValue { + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_SUCCESS: self = .success + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_UTF_DECODING_ERROR: self = .utfDecodingError + case SESSION_PROTOCOL_PRO_FEATURES_FOR_MSG_STATUS_EXCEEDS_CHARACTER_LIMIT: self = .exceedsCharacterLimit + default: self = .utfDecodingError + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift new file mode 100644 index 0000000000..6987bd77f1 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift @@ -0,0 +1,31 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct Features: OptionSet, Equatable, Hashable { + public let rawValue: UInt64 + + public static let none: Features = Features(rawValue: 0) + public static let extendedCharacterLimit: Features = Features(rawValue: 1 << 0) + public static let proBadge: Features = Features(rawValue: 1 << 1) + public static let animatedAvatar: Features = Features(rawValue: 1 << 2) + public static let all: Features = [ extendedCharacterLimit, proBadge, animatedAvatar ] + + var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES { + SESSION_PROTOCOL_PRO_FEATURES(rawValue) + } + + // MARK: - Initialization + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES) { + self = Features(rawValue: libSessionValue) + } + } +} + diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift new file mode 100644 index 0000000000..89f8a9084d --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift @@ -0,0 +1,34 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension SessionPro { + struct FeaturesForMessage: Equatable { + let status: FeatureStatus + let error: String? + let features: Features + let codePointCount: Int + + static let invalidString: FeaturesForMessage = FeaturesForMessage(status: .utfDecodingError) + + // MARK: - Initialization + + init(status: FeatureStatus, error: String? = nil, features: Features = [], codePointCount: Int = 0) { + self.status = status + self.error = error + self.features = features + self.codePointCount = codePointCount + } + + init(_ libSessionValue: session_protocol_pro_features_for_msg) { + status = FeatureStatus(libSessionValue.status) + error = libSessionValue.get(\.error, nullIfEmpty: true) + features = Features(libSessionValue.features) + codePointCount = libSessionValue.codepoint_count + } + } +} + +extension session_protocol_pro_features_for_msg: @retroactive CAccessible {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift new file mode 100644 index 0000000000..5a1160a6dd --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift @@ -0,0 +1,38 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension SessionPro { + enum ProStatus: Sendable, CaseIterable { + case none + case invalidProBackendSig + case invalidUserSig + case valid + case expired + + var libSessionValue: SESSION_PROTOCOL_PRO_STATUS { + switch self { + case .none: return SESSION_PROTOCOL_PRO_STATUS_NIL + case .invalidProBackendSig: return SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG + case .invalidUserSig: return SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG + case .valid: return SESSION_PROTOCOL_PRO_STATUS_VALID + case .expired: return SESSION_PROTOCOL_PRO_STATUS_EXPIRED + } + } + + init(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { + switch libSessionValue { + case SESSION_PROTOCOL_PRO_STATUS_NIL: self = .none + case SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG: self = .invalidProBackendSig + case SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG: self = .invalidUserSig + case SESSION_PROTOCOL_PRO_STATUS_VALID: self = .valid + case SESSION_PROTOCOL_PRO_STATUS_EXPIRED: self = .expired + default: self = .none + } + } + } +} diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 151479c9ce..d3d04379dc 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -33,30 +33,30 @@ public class DisplayPictureManager { case none case contactRemove - case contactUpdateTo(url: String, key: Data, contactProProof: String?) + case contactUpdateTo(url: String, key: Data, sessionProProof: Network.SessionPro.ProProof?) case currentUserRemove - case currentUserUpdateTo(url: String, key: Data, sessionProProof: String?, isReupload: Bool) + case currentUserUpdateTo(url: String, key: Data, sessionProProof: Network.SessionPro.ProProof?, isReupload: Bool) case groupRemove case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) case groupUpdateTo(url: String, key: Data) static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) + return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.proProof, fallback: fallback, using: dependencies) } public static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) + return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, contactProProof: profile.proProof, fallback: fallback, using: dependencies) } - static func from(_ url: String?, key: Data?, contactProProof: String?, fallback: Update, using dependencies: Dependencies) -> Update { + static func from(_ url: String?, key: Data?, contactProProof: Network.SessionPro.ProProof?, fallback: Update, using dependencies: Dependencies) -> Update { guard let url: String = url, let key: Data = key else { return fallback } - return .contactUpdateTo(url: url, key: key, contactProProof: contactProProof) + return .contactUpdateTo(url: url, key: key, sessionProProof: contactProProof) } } diff --git a/SessionMessagingKit/Utilities/SessionProManager.swift b/SessionMessagingKit/Utilities/SessionProManager.swift deleted file mode 100644 index 6ac2458c99..0000000000 --- a/SessionMessagingKit/Utilities/SessionProManager.swift +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionUIKit -import SessionNetworkingKit -import SessionUtilitiesKit - -// MARK: - Singleton - -public extension Singleton { - static let sessionProState: SingletonConfig = Dependencies.create( - identifier: "sessionProState", - createInstance: { dependencies in SessionProState(using: dependencies) } - ) -} - -// MARK: - SessionProState - -public class SessionProState: SessionProManagerType { - public let dependencies: Dependencies - public var isSessionProSubject: CurrentValueSubject - public var isSessionProPublisher: AnyPublisher { - isSessionProSubject - .filter { $0 } - .eraseToAnyPublisher() - } - - public init(using dependencies: Dependencies) { - self.dependencies = dependencies - self.isSessionProSubject = CurrentValueSubject(dependencies[cache: .libSession].isSessionPro) - } - - public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) { - dependencies.set(feature: .mockCurrentUserSessionPro, to: true) - self.isSessionProSubject.send(true) - completion?(true) - } - - @discardableResult @MainActor public func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { - return false - } - beforePresented?() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: variant, - dataManager: dependencies[singleton: .imageDataManager], - dismissType: dismissType, - afterClosed: afterClosed - ) - ) - presenting?(sessionProModal) - - return true - } -} diff --git a/SessionNetworkingKit/SessionPro/SessionPro.swift b/SessionNetworkingKit/SessionPro/SessionPro.swift index b129bca495..1a1f2ef3be 100644 --- a/SessionNetworkingKit/SessionPro/SessionPro.swift +++ b/SessionNetworkingKit/SessionPro/SessionPro.swift @@ -6,7 +6,7 @@ import Foundation public extension Network { enum SessionPro { - static let apiVersion: UInt8 = 0 + public static let apiVersion: UInt8 = 0 static let server = "{NEED_TO_SET}" static let serverPublicKey = "{NEED_TO_SET}" } diff --git a/SessionNetworkingKit/SessionPro/Types/ProStatus.swift b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift similarity index 85% rename from SessionNetworkingKit/SessionPro/Types/ProStatus.swift rename to SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift index 8641f75f6c..fb94feb4bf 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProStatus.swift +++ b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift @@ -7,7 +7,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - enum ProStatus: Sendable, CaseIterable, CustomStringConvertible { + enum BackendUserProStatus: Sendable, CaseIterable, CustomStringConvertible { case neverBeenPro case active case expired @@ -42,14 +42,14 @@ public extension Network.SessionPro { // MARK: - FeatureStorage public extension FeatureStorage { - static let mockCurrentUserSessionProStatus: FeatureConfig = Dependencies.create( - identifier: "mockCurrentUserSessionProStatus" + static let mockCurrentUserSessionProBackendStatus: FeatureConfig = Dependencies.create( + identifier: "mockCurrentUserSessionProBackendStatus" ) } // MARK: - Router -extension Optional: @retroactive RawRepresentable, @retroactive FeatureOption where Wrapped == Network.SessionPro.ProStatus { +extension Optional: @retroactive RawRepresentable, @retroactive FeatureOption where Wrapped == Network.SessionPro.BackendUserProStatus { public typealias RawValue = Int public var rawValue: Int { @@ -72,7 +72,7 @@ extension Optional: @retroactive RawRepresentable, @retroactive FeatureOption wh // MARK: - Feature Option - public static var defaultOption: Network.SessionPro.ProStatus? = nil + public static var defaultOption: Network.SessionPro.BackendUserProStatus? = nil public var title: String { (self.map { "\($0)" } ?? "None") } diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift index 399d8accfd..4c356f0c8b 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProProof.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -12,6 +12,22 @@ public extension Network.SessionPro { let expiryUnixTimestampMs: UInt64 let signature: [UInt8] + // MARK: - Initialization + + public init( + version: UInt8 = Network.SessionPro.apiVersion, + genIndexHash: [UInt8] = [], + rotatingPubkey: [UInt8] = [], + expiryUnixTimestampMs: UInt64 = 0, + signature: [UInt8] = [] + ) { + self.version = version + self.genIndexHash = genIndexHash + self.rotatingPubkey = rotatingPubkey + self.expiryUnixTimestampMs = expiryUnixTimestampMs + self.signature = signature + } + init(_ libSessionValue: session_protocol_pro_proof) { version = libSessionValue.version genIndexHash = libSessionValue.get(\.gen_index_hash) diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 600e3400ee..a078a9cc97 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -168,6 +168,7 @@ public struct ProCTAModal: View { Text(variant.benefits[index]) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index e548a40b47..1a46c732b8 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -69,9 +69,6 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private let threadId: String private let threadVariant: SessionThread.Variant private let isAddMoreVisible: Bool - private var isSessionPro: Bool { - dependencies[cache: .libSession].isSessionPro - } var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool @@ -673,7 +670,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) + .put(key: "limit", value: dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), @@ -714,7 +711,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { title: "modalMessageCharacterTooLongTitle".localized(), body: .text( "modalMessageTooLongDescription" - .put(key: "limit", value: (isSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit)) + .put(key: "limit", value: dependencies[singleton: .sessionProManager].characterLimit) .localized(), scrollMode: .never ), From 36bdbe747327321c4cbf343cfa22948f9dfae6e0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Nov 2025 15:10:01 +1100 Subject: [PATCH 12/66] Updated pro integration for breaking change --- .../SessionPro/Requests/GetProStatusRequest.swift | 4 ++-- .../SessionPro/Requests/GetProStatusResponse.swift | 6 ++++-- SessionNetworkingKit/SessionPro/SessionProAPI.swift | 12 ++++++------ SessionNetworkingKit/SessionPro/Types/ProProof.swift | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift index 9a70c4091a..7d2cd78e2f 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift @@ -8,7 +8,7 @@ public extension Network.SessionPro { struct GetProStatusRequest: Encodable, Equatable { public let masterPublicKey: [UInt8] public let timestampMs: UInt64 - public let includeHistory: Bool + public let count: UInt32 public let signature: Signature // MARK: - Functions @@ -19,7 +19,7 @@ public extension Network.SessionPro { result.set(\.master_pkey, to: masterPublicKey) result.set(\.master_sig, to: signature.signature) result.unix_ts_ms = timestampMs - result.history = includeHistory + result.count = count return result } diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift index 0ea9ff8f18..2b87370a31 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift @@ -8,11 +8,12 @@ public extension Network.SessionPro { struct GetProStatusResponse: Decodable, Equatable { public let header: ResponseHeader public let items: [PaymentItem] - public let status: ProStatus + public let status: BackendUserProStatus public let errorReport: ErrorReport public let autoRenewing: Bool public let expiryTimestampMs: UInt64 public let gracePeriodDurationMs: UInt64 + public let paymentsTotal: UInt32 public init(from decoder: any Decoder) throws { let container: SingleValueDecodingContainer = try decoder.singleValueContainer() @@ -45,11 +46,12 @@ public extension Network.SessionPro { defer { session_pro_backend_get_pro_status_response_free(&result) } self.header = ResponseHeader(result.header) - self.status = ProStatus(result.status) + self.status = BackendUserProStatus(result.status) self.errorReport = ErrorReport(result.error_report) self.autoRenewing = result.auto_renewing self.expiryTimestampMs = result.expiry_unix_ts_ms self.gracePeriodDurationMs = result.grace_period_duration_ms + self.paymentsTotal = result.payments_total if result.items_count > 0 { self.items = (0.. Network.PreparedRequest { - let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) +// static func test(using dependencies: Dependencies) throws -> Network.PreparedRequest { +// let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) +// let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) static func addProPaymentOrGetProProof( transactionId: String, masterKeyPair: KeyPair, @@ -94,7 +94,7 @@ public extension Network.SessionPro { } static func getProStatus( - includeHistory: Bool = false, + count: UInt32 = 1, masterKeyPair: KeyPair, using dependencies: Dependencies ) throws -> Network.PreparedRequest { @@ -106,7 +106,7 @@ public extension Network.SessionPro { cMasterPrivateKey, cMasterPrivateKey.count, timestampMs, - includeHistory + count ) ) @@ -117,7 +117,7 @@ public extension Network.SessionPro { body: GetProStatusRequest( masterPublicKey: masterKeyPair.publicKey, timestampMs: timestampMs, - includeHistory: includeHistory, + count: count, signature: signature ) ), diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift index 4c356f0c8b..d332789c48 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProProof.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -28,7 +28,7 @@ public extension Network.SessionPro { self.signature = signature } - init(_ libSessionValue: session_protocol_pro_proof) { + public init(_ libSessionValue: session_protocol_pro_proof) { version = libSessionValue.version genIndexHash = libSessionValue.get(\.gen_index_hash) rotatingPubkey = libSessionValue.get(\.rotating_pubkey) From 22bab8b74b78e9b4f2d3c0c7a138494566cbc698 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Nov 2025 15:27:20 +1100 Subject: [PATCH 13/66] Wired up the pro proof check to the truncation logic --- Session.xcodeproj/project.pbxproj | 24 ++ .../ConversationVC+Interaction.swift | 3 +- ...isappearingMessagesSettingsViewModel.swift | 2 +- Session/Onboarding/Onboarding.swift | 3 + Session/Settings/SettingsViewModel.swift | 21 +- .../Crypto/Crypto+LibSession.swift | 77 +++---- .../DisappearingMessageConfiguration.swift | 4 +- .../Database/Models/LinkPreview.swift | 23 +- .../Models/MessageDeduplication.swift | 4 +- .../Database/Models/Quote.swift | 16 -- .../Jobs/MessageReceiveJob.swift | 68 +++--- .../Jobs/ReuploadUserDisplayPictureJob.swift | 2 - .../Config Handling/LibSession+Contacts.swift | 3 +- .../LibSession+ConvoInfoVolatile.swift | 6 +- .../LibSession+UserProfile.swift | 1 - .../Messages/Decoding/DecodedEnvelope.swift | 60 +++++ .../Messages/Decoding/DecodedMessage.swift | 98 ++++++++ .../Messages/Decoding/Envelope.swift | 27 +++ .../Messages/Decoding/EnvelopeFlags.swift | 28 +++ SessionMessagingKit/Messages/Message.swift | 15 +- .../Messages/MessageError.swift | 76 ++++--- .../VisibleMessage+LinkPreview.swift | 42 ++-- .../VisibleMessage+Quote.swift | 18 +- .../Open Groups/OpenGroupManager.swift | 8 +- .../MessageReceiver+Calls.swift | 30 +-- ...eReceiver+DataExtractionNotification.swift | 2 +- .../MessageReceiver+ExpirationTimers.swift | 24 +- .../MessageReceiver+Groups.swift | 131 +++++------ .../MessageReceiver+MessageRequests.swift | 2 + .../MessageReceiver+VisibleMessages.swift | 214 ++++++++++-------- .../MessageSender+Groups.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 53 ++--- .../NotificationsManagerType.swift | 2 +- .../Pollers/SwarmPoller.swift | 4 +- .../SessionPro/SessionProManager.swift | 50 +++- .../SessionProDecodedProForMessage.swift | 2 +- .../Types/SessionProExtraFeatures.swift | 1 - .../SessionPro/Types/SessionProFeatures.swift | 7 +- .../SessionPro/Types/SessionProStatus.swift | 2 +- .../Utilities/DisplayPictureManager.swift | 12 +- .../Utilities/Profile+Updating.swift | 12 +- .../SessionPro/SessionProAPI.swift | 53 ++++- .../Types/BackendUserProStatus.swift | 2 +- .../SessionPro/Types/ProProof.swift | 15 +- .../NotificationServiceExtension.swift | 15 +- .../Utilities/TypeConversion+Utilities.swift | 44 ++++ .../AttachmentApprovalViewController.swift | 4 +- 47 files changed, 840 insertions(+), 472 deletions(-) create mode 100644 SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift create mode 100644 SessionMessagingKit/Messages/Decoding/DecodedMessage.swift create mode 100644 SessionMessagingKit/Messages/Decoding/Envelope.swift create mode 100644 SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 03280ee526..776b6e36d8 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -952,6 +952,10 @@ FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */; }; FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD62DC9A61600564172 /* NotificationCategory.swift */; }; FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */; }; + FD99A39F2EBAA5EA00E59F94 /* DecodedEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */; }; + FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A12EBAA6A500E59F94 /* Envelope.swift */; }; + FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; + FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -2251,6 +2255,10 @@ FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+DisplayName.swift"; sourceTree = ""; }; FD981BD62DC9A61600564172 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUserInfoKey.swift; sourceTree = ""; }; + FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedEnvelope.swift; sourceTree = ""; }; + FD99A3A12EBAA6A500E59F94 /* Envelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Envelope.swift; sourceTree = ""; }; + FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; + FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -3216,6 +3224,7 @@ C300A5BB2554AFFB00555489 /* Messages */ = { isa = PBXGroup; children = ( + FD99A3A02EBAA69600E59F94 /* Decoding */, C300A5C62554B02D00555489 /* Visible Messages */, C300A5C72554B03900555489 /* Control Messages */, C3C2A74325539EB700C340D1 /* Message.swift */, @@ -5024,6 +5033,17 @@ path = Utilities; sourceTree = ""; }; + FD99A3A02EBAA69600E59F94 /* Decoding */ = { + isa = PBXGroup; + children = ( + FD99A3A12EBAA6A500E59F94 /* Envelope.swift */, + FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */, + FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */, + FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */, + ); + path = Decoding; + sourceTree = ""; + }; FDAA16792AC28E2200DDBF77 /* Models */ = { isa = PBXGroup; children = ( @@ -6850,6 +6870,7 @@ FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */, FD2272782C32911C004D8A6C /* AttachmentDownloadJob.swift in Sources */, + FD99A39F2EBAA5EA00E59F94 /* DecodedEnvelope.swift in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FD428B232B4B9969006D0888 /* _031_RebuildFTSIfNeeded_2_4_5.swift in Sources */, FD2272742C32911C004D8A6C /* ConfigMessageReceiveJob.swift in Sources */, @@ -6908,6 +6929,7 @@ FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, + FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */, @@ -6917,6 +6939,7 @@ FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FD2272FE2C352D8E004D8A6C /* LibSession+GroupMembers.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, + FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, @@ -6990,6 +7013,7 @@ FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */, + FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */, FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fbab8245e0..e3af77e748 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -871,6 +871,7 @@ extension ConversationVC: fallback: .none, using: dependencies ), + decodedPro: dependencies[singleton: .sessionProManager].currentUserCurrentDecodedProForMessage, profileUpdateTimestamp: currentUserProfile.profileLastUpdated, using: dependencies ) @@ -1245,7 +1246,7 @@ extension ConversationVC: ) { [weak self, dependencies = viewModel.dependencies] _ in dependencies[singleton: .storage].writeAsync { db in let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let interactionId = try messageDisappearingConfig .upserted(db) diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index ff45656e18..1832fc318f 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -357,7 +357,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga // Update the local state try updatedConfig.upserted(db) - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let interactionId = try updatedConfig .upserted(db) .insertControlMessage( diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 1ad6a5d528..39d4efbfc6 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -412,6 +412,9 @@ extension Onboarding { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(displayName), displayPictureUpdate: .none, + // TODO: [PRO] Need to decide if this is accurate + /// We won't have the current users Session Pro state at this stage + decodedPro: nil, profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, using: dependencies ) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index b13eaf5641..b11c25339e 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -134,7 +134,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile - let sessionProStatus: Network.SessionPro.ProStatus? + let sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -159,7 +159,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), - sessionProStatus: nil, + sessionProBackendStatus: nil, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -193,7 +193,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile - var sessionProStatus: Network.SessionPro.ProStatus? = previousState.sessionProStatus + var sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? = previousState.sessionProBackendStatus var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -212,7 +212,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl /// If the device has a mock pro status set then use that if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { - sessionProStatus = dependencies[feature: .mockCurrentUserSessionProBackendStatus] + sessionProBackendStatus = dependencies[feature: .mockCurrentUserSessionProBackendStatus] } /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading @@ -265,7 +265,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return State( userSessionId: previousState.userSessionId, profile: profile, - sessionProStatus: sessionProStatus, + sessionProBackendStatus: sessionProBackendStatus, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -307,7 +307,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onTap: { [weak viewModel] in viewModel?.updateProfilePicture( currentUrl: state.profile.displayPictureUrl, - sessionProStatus: state.sessionProStatus + sessionProBackendStatus: state.sessionProBackendStatus ) } ), @@ -682,7 +682,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture( currentUrl: String?, - sessionProStatus: Network.SessionPro.ProStatus? + sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? ) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false @@ -709,7 +709,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { - switch (dependencies[feature: .sessionProEnabled], sessionProStatus) { + switch (dependencies[feature: .sessionProEnabled], sessionProBackendStatus) { case (false, _): return nil case (true, .active): return "proAnimatedDisplayPictureModalDescription" @@ -739,7 +739,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dataManager: dependencies[singleton: .imageDataManager], onProBageTapped: { [weak self, dependencies] in dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (sessionProStatus == .active)), + .animatedProfileImage(isSessionProActivated: (sessionProBackendStatus == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -788,7 +788,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isAnimatedImage && !dependencies[feature: .sessionProEnabled] { didShowCTAModal = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (sessionProStatus == .active)), + .animatedProfileImage(isSessionProActivated: (sessionProBackendStatus == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -870,7 +870,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: false ) } diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 15605e5e52..3e12111be2 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -18,6 +18,9 @@ public extension Crypto.Generator { args: [] ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + let cRotatingProPubkey: [UInt8]? = dependencies[singleton: .sessionProManager] + .currentUserCurrentRotatingKeyPair? + .publicKey guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } @@ -36,8 +39,8 @@ public extension Crypto.Generator { cEd25519SecretKey.count, sentTimestampMs, &cPubkey, - nil, - 0, + cRotatingProPubkey, + (cRotatingProPubkey?.count ?? 0), &error, error.count ) @@ -52,8 +55,8 @@ public extension Crypto.Generator { cEd25519SecretKey.count, sentTimestampMs, &cPubkey, - nil, - 0, + cRotatingProPubkey, + (cRotatingProPubkey?.count ?? 0), &error, error.count ) @@ -75,8 +78,8 @@ public extension Crypto.Generator { sentTimestampMs, &cPubkey, &cCurrentGroupEncPrivateKey, - nil, - 0, + cRotatingProPubkey, + (cRotatingProPubkey?.count ?? 0), &error, error.count ) @@ -85,8 +88,8 @@ public extension Crypto.Generator { result = session_protocol_encode_for_community( cPlaintext, cPlaintext.count, - nil, - 0, + cRotatingProPubkey, + (cRotatingProPubkey?.count ?? 0), &error, error.count ) @@ -104,8 +107,8 @@ public extension Crypto.Generator { sentTimestampMs, &cRecipientPubkey, &cServerPubkey, - nil, - 0, + cRotatingProPubkey, + (cRotatingProPubkey?.count ?? 0), &error, error.count ) @@ -124,40 +127,35 @@ public extension Crypto.Generator { static func decodedMessage( encodedMessage: I, origin: Message.Origin - ) throws -> Crypto.Generator<(proto: SNProtoContent, sender: String, sentTimestampMs: UInt64)> { + ) throws -> Crypto.Generator { return Crypto.Generator( id: "decodedMessage", args: [] ) { dependencies in let cEncodedMessage: [UInt8] = Array(encodedMessage) + let cBackendPubkey: [UInt8] = Array(Data(hex: Network.SessionPro.serverPublicKey)) let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var error: [CChar] = [CChar](repeating: 0, count: 256) switch origin { case .community(_, let sender, let posted, _, _, _, _): - var result: session_protocol_decoded_community_message = session_protocol_decode_for_community( + var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( cEncodedMessage, cEncodedMessage.count, currentTimestampMs, - nil, - 0, + cBackendPubkey, + cBackendPubkey.count, &error, error.count ) - defer { session_protocol_decode_for_community_free(&result) } + defer { session_protocol_decode_for_community_free(&cResult) } - guard result.success else { + guard cResult.success else { Log.error(.messageSender, "Failed to decode community message due to error: \(String(cString: error))") throw MessageError.decodingFailed } - let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext_unpadded_size)) - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) - - return (proto, sender, sentTimestampMs) + return try DecodedMessage(decodedValue: cResult, sender: sender, posted: posted) case .communityInbox(let posted, _, let serverPublicKey, let senderId, let recipientId): // FIXME: Fold into `session_protocol_decode_envelope` once support is added @@ -169,14 +167,14 @@ public extension Crypto.Generator { serverPublicKey: serverPublicKey ) ) + let plaintext: Data = plaintextWithPadding.removePadding() - let plaintext = plaintextWithPadding.removePadding() - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) - - return (proto, sender, sentTimestampMs) + return DecodedMessage( + content: plaintext, + sender: try SessionId(from: senderId), + decodedEnvelope: nil, // TODO: [PRO] If we don't set this then we won't know the pro status + sentTimestampMs: UInt64(floor(posted * 1000)) + ) case .swarm(let publicKey, let namespace, _, _, _): /// Function to provide pointers to the keys based on the namespace the message was received from @@ -229,30 +227,24 @@ public extension Crypto.Generator { cKeys.set(\.group_ed25519_pubkey, to: cPublicKey) } - var result: session_protocol_decoded_envelope = session_protocol_decode_envelope( + var cResult: session_protocol_decoded_envelope = session_protocol_decode_envelope( &cKeys, cEncodedMessage, cEncodedMessage.count, currentTimestampMs, - nil, - 0, + cBackendPubkey, + cBackendPubkey.count, &error, error.count ) - defer { session_protocol_decode_envelope_free(&result) } + defer { session_protocol_decode_envelope_free(&cResult) } - guard result.success else { + guard cResult.success else { Log.error(.messageReceiver, "Failed to decode message due to error: \(String(cString: error))") throw MessageError.decodingFailed } - let plaintext: Data = Data(UnsafeBufferPointer(start: result.content_plaintext.data, count: result.content_plaintext.size)) - let proto: SNProtoContent = try Result(catching: { try SNProtoContent.parseData(plaintext) }) - .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } - .get() - let sender: SessionId = SessionId(.standard, publicKey: result.get(\.sender_x25519_pubkey)) - - return (proto, sender.hexString, result.envelope.timestamp_ms) + return DecodedMessage(decodedValue: cResult) } } } @@ -407,4 +399,3 @@ extension bytes32: CAccessible & CMutable {} extension bytes33: CAccessible & CMutable {} extension bytes64: CAccessible & CMutable {} extension session_protocol_decode_envelope_keys: CAccessible & CMutable {} -extension session_protocol_decoded_envelope: CAccessible & CMutable {} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 10e9faf495..4d3ae305f3 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -305,7 +305,7 @@ public extension DisappearingMessagesConfiguration { _ db: ObservingDatabase, threadVariant: SessionThread.Variant, authorId: String, - timestampMs: Int64, + timestampMs: UInt64, serverHash: String?, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies @@ -368,7 +368,7 @@ public extension DisappearingMessagesConfiguration { ), using: dependencies ), - timestampMs: timestampMs, + timestampMs: Int64(timestampMs), wasRead: wasRead, expiresInSeconds: interactionExpirationInfo?.expiresInSeconds, expiresStartedAtMs: interactionExpirationInfo?.expiresStartedAtMs, diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 9f53541edb..3452285efc 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -82,15 +82,17 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis // MARK: - Protobuf public extension LinkPreview { - init?(_ db: ObservingDatabase, proto: SNProtoDataMessage, sentTimestampMs: TimeInterval) throws { - guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } - guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } - guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } + init?( + _ db: ObservingDatabase, + linkPreview: VisibleMessage.VMLinkPreview, + sentTimestampMs: UInt64 + ) throws { + guard LinkPreview.isValidLinkUrl(linkPreview.url) else { throw LinkPreviewError.invalidInput } // Try to get an existing link preview first let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) let maybeLinkPreview: LinkPreview? = try? LinkPreview - .filter(LinkPreview.Columns.url == previewProto.url) + .filter(LinkPreview.Columns.url == linkPreview.url) .filter(LinkPreview.Columns.timestamp == timestamp) .fetchOne(db) @@ -99,13 +101,12 @@ public extension LinkPreview { return } - self.url = previewProto.url + self.url = linkPreview.url self.timestamp = timestamp self.variant = .standard - self.title = LinkPreview.normalizeTitle(title: previewProto.title) + self.title = LinkPreview.normalizeTitle(title: linkPreview.title) - if let imageProto = previewProto.image { - let attachment: Attachment = Attachment(proto: imageProto) + if let attachment: Attachment = linkPreview.nonInsertedAttachment { try attachment.insert(db) self.attachmentId = attachment.id @@ -127,10 +128,10 @@ public extension LinkPreview { let matchRange: NSRange } - static func timestampFor(sentTimestampMs: Double) -> TimeInterval { + static func timestampFor(sentTimestampMs: UInt64) -> TimeInterval { // We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler // than 86,400) to optimise LinkPreview storage without having too stale data - return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) + return (floor(Double(sentTimestampMs) / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } static func prepareAttachmentIfPossible( diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift index e85c8dd062..8d4edffc26 100644 --- a/SessionMessagingKit/Database/Models/MessageDeduplication.swift +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -355,7 +355,7 @@ public extension MessageDeduplication { /// special then it could result in unexpected behaviours where config messages don't get merged correctly switch processedMessage { case .config: return - case .standard(_, let threadVariant, _, let messageInfo, _): + case .standard(_, let threadVariant, let messageInfo, _): try insert( db, threadId: processedMessage.threadId, @@ -491,7 +491,7 @@ private extension MessageDeduplication { static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? { switch processedMessage { case .config: return nil - case .standard(_, _, _, let messageInfo, _): + case .standard(_, _, let messageInfo, _): guard let timestampMs: UInt64 = messageInfo.message.sentTimestampMs, let variant: _040_MessageDeduplicationTable.ControlMessageProcessRecordVariant = getLegacyVariant(for: Message.Variant(from: messageInfo.message)) diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index c13fde3ebc..8373c22bd3 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -59,19 +59,3 @@ public extension Quote { ) } } - -// MARK: - Protobuf - -public extension Quote { - init?(proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { - guard - let quoteProto = proto.quote, - quoteProto.id != 0, - !quoteProto.author.isEmpty - else { return nil } - - self.interactionId = interactionId - self.timestampMs = Int64(quoteProto.id) - self.authorId = quoteProto.author - } -} diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index 129d2d2997..beb35792fe 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -35,33 +35,18 @@ public enum MessageReceiveJob: JobExecutor { var updatedJob: Job = job var lastError: Error? var remainingMessagesToProcess: [Details.MessageInfo] = [] - let messageData: [(info: Details.MessageInfo, proto: SNProtoContent)] = details.messages - .compactMap { messageInfo -> (info: Details.MessageInfo, proto: SNProtoContent)? in - do { - return (messageInfo, try SNProtoContent.parseData(messageInfo.serializedProtoData)) - } - catch { - Log.error(.cat, "Couldn't receive message due to error: \(error)") - lastError = error - - // We failed to process this message but it is a retryable error - // so add it to the list to re-process - remainingMessagesToProcess.append(messageInfo) - return nil - } - } dependencies[singleton: .storage].writeAsync( updates: { db -> Error? in - for (messageInfo, protoContent) in messageData { + for messageInfo in details.messages { do { let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( db, threadId: threadId, threadVariant: messageInfo.threadVariant, message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: protoContent, suppressNotifications: false, using: dependencies ) @@ -145,41 +130,29 @@ extension MessageReceiveJob { case variant case threadVariant case serverExpirationTimestamp + @available(*, deprecated, message: "'serializedProtoData' has been removed, access `decodedMesage` instead") case serializedProtoData + case decodedMessage } public let message: Message public let variant: Message.Variant public let threadVariant: SessionThread.Variant public let serverExpirationTimestamp: TimeInterval? - public let serializedProtoData: Data + public let decodedMessage: DecodedMessage public init( message: Message, variant: Message.Variant, threadVariant: SessionThread.Variant, serverExpirationTimestamp: TimeInterval?, - proto: SNProtoContent - ) throws { - self.message = message - self.variant = variant - self.threadVariant = threadVariant - self.serverExpirationTimestamp = serverExpirationTimestamp - self.serializedProtoData = try proto.serializedData() - } - - private init( - message: Message, - variant: Message.Variant, - threadVariant: SessionThread.Variant, - serverExpirationTimestamp: TimeInterval?, - serializedProtoData: Data + decodedMessage: DecodedMessage ) { self.message = message self.variant = variant self.threadVariant = threadVariant self.serverExpirationTimestamp = serverExpirationTimestamp - self.serializedProtoData = serializedProtoData + self.decodedMessage = decodedMessage } // MARK: - Codable @@ -192,12 +165,31 @@ extension MessageReceiveJob { throw StorageError.decodingFailed } + let message: Message = try variant.decode(from: container, forKey: .message) + // FIXME: Remove this once pro has been out for long enough + let decodedMessage: DecodedMessage + if + let sender: SessionId = try? SessionId(from: message.sender), + let sentTimestampMs: UInt64 = message.sentTimestampMs, + let legacyProtoData: Data = try container.decodeIfPresent(Data.self, forKey: .serializedProtoData) + { + decodedMessage = DecodedMessage( + content: legacyProtoData, + sender: sender, + decodedEnvelope: nil, + sentTimestampMs: sentTimestampMs + ) + } + else { + decodedMessage = try container.decode(DecodedMessage.self, forKey: .decodedMessage) + } + self = MessageInfo( - message: try variant.decode(from: container, forKey: .message), + message: message, variant: variant, threadVariant: try container.decode(SessionThread.Variant.self, forKey: .threadVariant), serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp), - serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData) + decodedMessage: decodedMessage ) } @@ -213,7 +205,7 @@ extension MessageReceiveJob { try container.encode(variant, forKey: .variant) try container.encode(threadVariant, forKey: .threadVariant) try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp) - try container.encode(serializedProtoData, forKey: .serializedProtoData) + try container.encode(decodedMessage, forKey: .decodedMessage) } } @@ -223,7 +215,7 @@ extension MessageReceiveJob { self.messages = messages.compactMap { processedMessage in switch processedMessage { case .config: return nil - case .standard(_, _, _, let messageInfo, _): return messageInfo + case .standard(_, _, let messageInfo, _): return messageInfo } } } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 48edf8df2c..82bc080587 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -113,7 +113,6 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: displayPictureUrl.absoluteString, key: displayPictureEncryptionKey, - sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: true ), using: dependencies @@ -173,7 +172,6 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: true ), using: dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 402670193e..5687e06d1d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -71,8 +71,7 @@ internal extension LibSessionCacheType { return .contactUpdateTo( url: displayPictureUrl, - key: displayPictureEncryptionKey, - sessionProProof: getProProof() // TODO: [PRO] double check if this is needed after Pro Proof is implemented + key: displayPictureEncryptionKey ) }(), nicknameUpdate: .set(to: data.profile.nickname), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index b45838b404..56d3a80de3 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -490,16 +490,16 @@ public extension LibSessionCacheType { func timestampAlreadyRead( threadId: String, threadVariant: SessionThread.Variant, - timestampMs: Int64, + timestampMs: UInt64, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Bool { - let lastReadTimestampMs = conversationLastRead( + let lastReadTimestampMs: Int64? = conversationLastRead( threadId: threadId, threadVariant: threadVariant, openGroupUrlInfo: openGroupUrlInfo ) - return ((lastReadTimestampMs ?? 0) >= timestampMs) + return ((lastReadTimestampMs ?? 0) >= Int64(timestampMs)) } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 1bf181b52e..ac92ba0e5c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -56,7 +56,6 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, isReupload: false ) }(), diff --git a/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift b/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift new file mode 100644 index 0000000000..b86aac51fb --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/DecodedEnvelope.swift @@ -0,0 +1,60 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public struct DecodedEnvelope: Sendable, Codable, Equatable { + let success: Bool + let envelope: Envelope + let content: Data + + /// The `ed25519` public key of the sender + /// + /// **Note:** Messages sent to a SOGS are not encrypted so this value will be `null` + let senderEd25519Pubkey: [UInt8]? + let senderX25519Pubkey: [UInt8] + let decodedPro: SessionPro.DecodedProForMessage + let errorLenInclNullTerminator: Int + + /// The timestamp that the message was sent from the senders device + /// + /// **Note:** For a message from SOGS this value is the timestamp the message was received by the server instead of the value + /// contained within the `Envelope` + let sentTimestampMs: UInt64 + + // MARK: - Initialization + + init( + success: Bool, + envelope: Envelope, + content: Data, + senderEd25519Pubkey: [UInt8]?, + senderX25519Pubkey: [UInt8], + decodedPro: SessionPro.DecodedProForMessage, + errorLenInclNullTerminator: Int, + sentTimestampMs: UInt64 + ) { + self.success = success + self.envelope = envelope + self.content = content + self.senderEd25519Pubkey = senderEd25519Pubkey + self.senderX25519Pubkey = senderX25519Pubkey + self.decodedPro = decodedPro + self.errorLenInclNullTerminator = errorLenInclNullTerminator + self.sentTimestampMs = sentTimestampMs + } + + init(_ libSessionValue: session_protocol_decoded_envelope) { + success = libSessionValue.success + envelope = Envelope(libSessionValue.envelope) + content = libSessionValue.get(\.content_plaintext) + senderEd25519Pubkey = libSessionValue.get(\.sender_ed25519_pubkey) + senderX25519Pubkey = libSessionValue.get(\.sender_x25519_pubkey) + decodedPro = SessionPro.DecodedProForMessage(libSessionValue.pro) + errorLenInclNullTerminator = libSessionValue.error_len_incl_null_terminator + sentTimestampMs = envelope.timestampMs + } +} + +extension session_protocol_decoded_envelope: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift new file mode 100644 index 0000000000..d29394ac7e --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift @@ -0,0 +1,98 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public struct DecodedMessage: Codable, Equatable { + static let empty: DecodedMessage = DecodedMessage( + content: Data(), + sender: .invalid, + decodedEnvelope: nil, + sentTimestampMs: 0 + ) + + public let content: Data + public let sender: SessionId + + /// The decoded envelope data + /// + /// **Note:** For legacy SOGS messages this value will be `null` + public let decodedEnvelope: DecodedEnvelope? + + /// The timestamp that the message was sent from the senders device + /// + /// **Note:** For a message from SOGS this value is the timestamp the message was received by the server instead of the value + /// contained within the `Envelope` + public let sentTimestampMs: UInt64 + + // MARK: - Convenience forwarded access + + var senderEd25519Pubkey: [UInt8]? { decodedEnvelope?.senderEd25519Pubkey } + var senderX25519Pubkey: [UInt8]? { decodedEnvelope?.senderX25519Pubkey } + var decodedPro: SessionPro.DecodedProForMessage? { decodedEnvelope?.decodedPro } + + // MARK: - Initialization + + init( + content: Data, + sender: SessionId, + decodedEnvelope: DecodedEnvelope?, + sentTimestampMs: UInt64 + ) { + self.content = content + self.sender = sender + self.decodedEnvelope = decodedEnvelope + self.sentTimestampMs = sentTimestampMs + } + + init(decodedValue: session_protocol_decoded_envelope) { + let decodedEnvelope: DecodedEnvelope = DecodedEnvelope(decodedValue) + + self = DecodedMessage( + content: decodedEnvelope.content, + sender: SessionId(.standard, publicKey: decodedEnvelope.senderX25519Pubkey), + decodedEnvelope: decodedEnvelope, + sentTimestampMs: decodedEnvelope.envelope.timestampMs + ) + } + + init( + decodedValue: session_protocol_decoded_community_message, + sender: String, + posted: TimeInterval + ) throws { + let content: Data = decodedValue.get(\.content_plaintext) + let senderSessionId: SessionId = try SessionId(from: sender) + + self = DecodedMessage( + content: content.prefix(decodedValue.content_plaintext_unpadded_size), + sender: senderSessionId, + decodedEnvelope: { + guard decodedValue.has_envelope else { return nil } + + return DecodedEnvelope( + success: decodedValue.success, + envelope: Envelope(decodedValue.envelope), + content: content, + senderEd25519Pubkey: nil, /// SOGS doesn't include the senders `ed25519` key + senderX25519Pubkey: senderSessionId.publicKey, + decodedPro: SessionPro.DecodedProForMessage(decodedValue.pro), + errorLenInclNullTerminator: decodedValue.error_len_incl_null_terminator, + sentTimestampMs: UInt64(floor(posted * 1000)) + ) + }(), + sentTimestampMs: UInt64(floor(posted * 1000)) + ) + } + + // MARK: - Functions + + public func decodeProtoContent() throws -> SNProtoContent { + return try Result(catching: { try SNProtoContent.parseData(content) }) + .onFailure { Log.error(.messageReceiver, "Couldn't parse proto due to error: \($0).") } + .get() + } +} + +extension session_protocol_decoded_community_message: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Messages/Decoding/Envelope.swift b/SessionMessagingKit/Messages/Decoding/Envelope.swift new file mode 100644 index 0000000000..305717c2b5 --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/Envelope.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +struct Envelope: Sendable, Codable, Equatable { + let flags: EnvelopeFlags + let timestampMs: UInt64 + let source: [UInt8] + let sourceDevice: UInt32 + let serverTimestamp: UInt64 + let proSignature: [UInt8] + + // MARK: - Initialization + + init(_ libSessionValue: session_protocol_envelope) { + flags = EnvelopeFlags(libSessionValue.flags) + timestampMs = libSessionValue.timestamp_ms + source = libSessionValue.get(\.source) + sourceDevice = libSessionValue.source_device + serverTimestamp = libSessionValue.server_timestamp + proSignature = libSessionValue.get(\.pro_sig) + } +} + +extension session_protocol_envelope: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift b/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift new file mode 100644 index 0000000000..93fef2a20e --- /dev/null +++ b/SessionMessagingKit/Messages/Decoding/EnvelopeFlags.swift @@ -0,0 +1,28 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +struct EnvelopeFlags: OptionSet, Sendable, Codable, Equatable, Hashable { + public let rawValue: UInt32 + + public static let source: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 0) + public static let sourceDevice: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 1) + public static let serverTimestamp: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 2) + public static let proSignature: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 3) + public static let timestamp: EnvelopeFlags = EnvelopeFlags(rawValue: 1 << 4) + + var libSessionValue: SESSION_PROTOCOL_ENVELOPE_FLAGS { + SESSION_PROTOCOL_ENVELOPE_FLAGS(rawValue) + } + + // MARK: - Initialization + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(_ libSessionValue: SESSION_PROTOCOL_ENVELOPE_FLAGS) { + self = EnvelopeFlags(rawValue: libSessionValue) + } +} diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 594760367f..5dbb460e75 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -171,7 +171,6 @@ public enum ProcessedMessage { case standard( threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, uniqueIdentifier: String ) @@ -186,14 +185,14 @@ public enum ProcessedMessage { public var threadId: String { switch self { - case .standard(let threadId, _, _, _, _): return threadId + case .standard(let threadId, _, _, _): return threadId case .config(let publicKey, _, _, _, _, _): return publicKey } } var namespace: Network.SnodeAPI.Namespace { switch self { - case .standard(_, let threadVariant, _, _, _): + case .standard(_, let threadVariant, _, _): switch threadVariant { case .group: return .groupMessages case .legacyGroup: return .legacyClosedGroup @@ -206,7 +205,7 @@ public enum ProcessedMessage { var uniqueIdentifier: String { switch self { - case .standard(_, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .standard(_, _, _, let uniqueIdentifier): return uniqueIdentifier case .config(_, _, _, _, _, let uniqueIdentifier): return uniqueIdentifier } } @@ -358,18 +357,18 @@ public extension Message { } public extension Message { - static func createMessageFrom(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) throws -> Message { - let decodedMessage: Message? = Variant + static func createMessageFrom(_ proto: SNProtoContent, decodedMessage: DecodedMessage, using dependencies: Dependencies) throws -> Message { + let result: Message? = Variant .allCases .sorted { lhs, rhs -> Bool in lhs.protoPriority < rhs.protoPriority } .filter { variant -> Bool in variant.isProtoConvetible } .reduce(nil) { prev, variant in guard prev == nil else { return prev } - return variant.messageType.fromProto(proto, sender: sender, using: dependencies) + return variant.messageType.fromProto(proto, sender: decodedMessage.sender.hexString, using: dependencies) } - return try decodedMessage ?? { throw MessageError.unknownMessage(proto) }() + return try result ?? { throw MessageError.unknownMessage(decodedMessage) }() } static func shouldSync(message: Message) -> Bool { diff --git a/SessionMessagingKit/Messages/MessageError.swift b/SessionMessagingKit/Messages/MessageError.swift index f6a1f1329f..c42c4d30f5 100644 --- a/SessionMessagingKit/Messages/MessageError.swift +++ b/SessionMessagingKit/Messages/MessageError.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import SessionUIKit import SessionUtilitiesKit public enum MessageError: Error, CustomStringConvertible { @@ -18,7 +19,7 @@ public enum MessageError: Error, CustomStringConvertible { case ignorableMessageRequestMessage case deprecatedMessage case protoConversionFailed - case unknownMessage(SNProtoContent?) + case unknownMessage(DecodedMessage) case requiredSignatureMissing case invalidConfigMessageHandling @@ -63,40 +64,49 @@ public enum MessageError: Error, CustomStringConvertible { case .ignorableMessageRequestMessage: return "Message request message should be ignored." case .deprecatedMessage: return "This message type has been deprecated." case .protoConversionFailed: return "Failed to convert to protobuf message." - case .unknownMessage(let content): - switch content { - case .none: return "Unknown message type (no content)." - case .some(let content): - let protoInfo: [(String, Bool)] = [ - ("hasDataMessage", (content.dataMessage != nil)), - ("hasProfile", (content.dataMessage?.profile != nil)), - ("hasBody", (content.dataMessage?.hasBody == true)), - ("hasAttachments", (content.dataMessage?.attachments.isEmpty == false)), - ("hasReaction", (content.dataMessage?.reaction != nil)), - ("hasQuote", (content.dataMessage?.quote != nil)), - ("hasLinkPreview", (content.dataMessage?.preview != nil)), - ("hasOpenGroupInvitation", (content.dataMessage?.openGroupInvitation != nil)), - ("hasGroupV2ControlMessage", (content.dataMessage?.groupUpdateMessage != nil)), - ("hasTimestamp", (content.dataMessage?.hasTimestamp == true)), - ("hasSyncTarget", (content.dataMessage?.hasSyncTarget == true)), - ("hasBlocksCommunityMessageRequests", (content.dataMessage?.hasBlocksCommunityMessageRequests == true)), - ("hasCallMessage", (content.callMessage != nil)), - ("hasReceiptMessage", (content.receiptMessage != nil)), - ("hasTypingMessage", (content.typingMessage != nil)), - ("hasDataExtractionMessage", (content.dataExtractionNotification != nil)), - ("hasUnsendRequest", (content.unsendRequest != nil)), - ("hasMessageRequestResponse", (content.messageRequestResponse != nil)), - ("hasExpirationTimer", (content.hasExpirationTimer == true)), - ("hasExpirationType", (content.hasExpirationType == true)), - ("hasSigTimestamp", (content.hasSigTimestamp == true)) - ] - - let protoInfoString: String = protoInfo + case .unknownMessage(let decodedMessage): + var messageInfo: [String] = [ + "size: \(Format.fileSize(UInt(decodedMessage.content.count)))" + ] + + if decodedMessage.decodedEnvelope != nil { + messageInfo.append("hasDecodedEnvelope") + } + + if let proto: SNProtoContent = try? decodedMessage.decodeProtoContent() { + let protoInfo: [(String, Bool)] = [ + ("hasDataMessage", (proto.dataMessage != nil)), + ("hasProfile", (proto.dataMessage?.profile != nil)), + ("hasBody", (proto.dataMessage?.hasBody == true)), + ("hasAttachments", (proto.dataMessage?.attachments.isEmpty == false)), + ("hasReaction", (proto.dataMessage?.reaction != nil)), + ("hasQuote", (proto.dataMessage?.quote != nil)), + ("hasLinkPreview", (proto.dataMessage?.preview != nil)), + ("hasOpenGroupInvitation", (proto.dataMessage?.openGroupInvitation != nil)), + ("hasGroupV2ControlMessage", (proto.dataMessage?.groupUpdateMessage != nil)), + ("hasTimestamp", (proto.dataMessage?.hasTimestamp == true)), + ("hasSyncTarget", (proto.dataMessage?.hasSyncTarget == true)), + ("hasBlocksCommunityMessageRequests", (proto.dataMessage?.hasBlocksCommunityMessageRequests == true)), + ("hasCallMessage", (proto.callMessage != nil)), + ("hasReceiptMessage", (proto.receiptMessage != nil)), + ("hasTypingMessage", (proto.typingMessage != nil)), + ("hasDataExtractionMessage", (proto.dataExtractionNotification != nil)), + ("hasUnsendRequest", (proto.unsendRequest != nil)), + ("hasMessageRequestResponse", (proto.messageRequestResponse != nil)), + ("hasExpirationTimer", (proto.hasExpirationTimer == true)), + ("hasExpirationType", (proto.hasExpirationType == true)), + ("hasSigTimestamp", (proto.hasSigTimestamp == true)) + ] + + messageInfo.append( + contentsOf: protoInfo .filter { _, val in val } - .map { name, _ in name } - .joined(separator: ", ") - return "Unknown message type (\(protoInfoString))." + .map { name, _ in "proto.\(name)" } + ) } + + let infoString: String = messageInfo.joined(separator: ", ") + return "Unknown message type (\(infoString))." case .requiredSignatureMissing: return "Required signature missing." case .invalidConfigMessageHandling: return "Invalid handling of a config message." diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 868654cafe..8c42879ad3 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -5,40 +5,48 @@ import SessionUtilitiesKit public extension VisibleMessage { struct VMLinkPreview: Codable { + public let url: String public let title: String? - public let url: String? public let attachmentId: String? + public let nonInsertedAttachment: Attachment? public func validateMessage(isSending: Bool) throws { - if title?.isEmpty != false { throw MessageError.invalidMessage("title") } - if url?.isEmpty != false { throw MessageError.invalidMessage("url") } + if !url.isEmpty { throw MessageError.invalidMessage("url") } } // MARK: - Initialization - internal init(title: String?, url: String, attachmentId: String?) { - self.title = title + internal init( + url: String, + title: String?, + attachmentId: String?, + nonInsertedAttachment: Attachment? + ) { self.url = url + self.title = title self.attachmentId = attachmentId + self.nonInsertedAttachment = nonInsertedAttachment } // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessagePreview) -> VMLinkPreview? { + guard + !proto.url.isEmpty, + LinkPreview.isValidLinkUrl(proto.url) + else { return nil } + return VMLinkPreview( - title: proto.title, url: proto.url, - attachmentId: nil + title: proto.title, + attachmentId: nil, + nonInsertedAttachment: proto.image.map { Attachment(proto: $0) } ) } public func toProto() -> SNProtoDataMessagePreview? { - guard let url = url else { - Log.warn(.messageSender, "Couldn't construct link preview proto from: \(self).") - return nil - } let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url) - if let title = title { linkPreviewProto.setTitle(title) } + if let title: String = title, !title.isEmpty { linkPreviewProto.setTitle(title) } do { return try linkPreviewProto.build() @@ -53,9 +61,10 @@ public extension VisibleMessage { public var description: String { """ LinkPreview( + url: \(url), title: \(title ?? "null"), - url: \(url ?? "null"), - attachmentId: \(attachmentId ?? "null") + attachmentId: \(attachmentId ?? "null"), + nonInsertedAttachment: \(nonInsertedAttachment.map { "\($0)" } ?? "null") ) """ } @@ -67,9 +76,10 @@ public extension VisibleMessage { public extension VisibleMessage.VMLinkPreview { static func from(linkPreview: LinkPreview) -> VisibleMessage.VMLinkPreview { return VisibleMessage.VMLinkPreview( - title: linkPreview.title, url: linkPreview.url, - attachmentId: linkPreview.attachmentId + title: linkPreview.title, + attachmentId: linkPreview.attachmentId, + nonInsertedAttachment: nil ) } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 26f63fff00..4eff1d33d2 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -6,12 +6,12 @@ import SessionUtilitiesKit public extension VisibleMessage { struct VMQuote: Codable { - public let timestamp: UInt64? - public let authorId: String? + public let timestamp: UInt64 + public let authorId: String public func validateMessage(isSending: Bool) throws { - if (timestamp ?? 0) == 0 { throw MessageError.invalidMessage("timestamp") } - if authorId?.isEmpty != false { throw MessageError.invalidMessage("authorId") } + if timestamp == 0 { throw MessageError.invalidMessage("timestamp") } + if !authorId.isEmpty { throw MessageError.invalidMessage("authorId") } } // MARK: - Initialization @@ -24,6 +24,8 @@ public extension VisibleMessage { // MARK: - Proto Conversion public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { + guard proto.id != 0 && !proto.author.isEmpty else { return nil } + return VMQuote( timestamp: proto.id, authorId: proto.author @@ -31,10 +33,6 @@ public extension VisibleMessage { } public func toProto() -> SNProtoDataMessageQuote? { - guard let timestamp = timestamp, let authorId = authorId else { - Log.warn(.messageSender, "Couldn't construct quote proto from: \(self).") - return nil - } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) do { return try quoteProto.build() @@ -49,8 +47,8 @@ public extension VisibleMessage { public var description: String { """ Quote( - timestamp: \(timestamp?.description ?? "null"), - authorId: \(authorId ?? "null") + timestamp: \(timestamp), + authorId: \(authorId) ) """ } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index eecb2c6be2..85221c4c8a 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -589,15 +589,15 @@ public final class OpenGroupManager { switch processedMessage { case .config: break - case .standard(_, _, _, let messageInfo, _): + case .standard(_, _, let messageInfo, _): insertedInteractionInfo.append( try MessageReceiver.handle( db, threadId: openGroup.id, threadVariant: .community, message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), suppressNotifications: false, using: dependencies ) @@ -745,7 +745,7 @@ public final class OpenGroupManager { switch processedMessage { case .config: break - case .standard(let threadId, _, let proto, let messageInfo, _): + case .standard(let threadId, _, let messageInfo, _): /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the /// "expensive" lookup when possible let lookup: BlindedIdLookup = try { @@ -796,8 +796,8 @@ public final class OpenGroupManager { threadId: (lookup.sessionId ?? lookup.blindedId), threadVariant: .contact, // Technically not open group messages message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, suppressNotifications: false, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 7fdff412e7..715b61313e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -21,6 +21,7 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { @@ -36,6 +37,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -57,6 +59,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -74,6 +77,7 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { @@ -87,7 +91,6 @@ extension MessageReceiver { // for this call would be dropped because of no Session call instance guard dependencies[singleton: .appContext].isMainApp, - let sender: String = message.sender, dependencies.mutate(cache: .libSession, { cache in !cache.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) @@ -98,7 +101,7 @@ extension MessageReceiver { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, - id: sender, + id: decodedMessage.sender.hexString, variant: .contact, values: .existingOrDefault, using: dependencies @@ -165,7 +168,7 @@ extension MessageReceiver { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, threadId: threadId, threadVariant: threadVariant, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, - id: sender, + id: decodedMessage.sender.hexString, variant: .contact, values: .existingOrDefault, using: dependencies @@ -220,7 +223,7 @@ extension MessageReceiver { NotificationCenter.default.post( name: .missedCall, object: nil, - userInfo: [ Notification.Key.senderId.rawValue: sender ] + userInfo: [ Notification.Key.senderId.rawValue: decodedMessage.sender.hexString ] ) return (threadId, threadVariant, interactionId, interaction.variant, interaction.wasRead, 0) } @@ -238,6 +241,7 @@ extension MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -263,7 +267,7 @@ extension MessageReceiver { /// Handle UI for the new call dependencies[singleton: .callManager].showCallUIForCall( - caller: sender, + caller: decodedMessage.sender.hexString, uuid: message.uuid, mode: .answer, interactionId: interaction?.id @@ -355,37 +359,33 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: CallMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard - let caller: String = message.sender, let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo), dependencies.mutate(cache: .libSession, { cache in - !cache.isMessageRequest(threadId: caller, threadVariant: threadVariant) + !cache.isMessageRequest(threadId: decodedMessage.sender.hexString, threadVariant: threadVariant) }) else { throw MessageError.missingRequiredField } - let messageSentTimestampMs: Int64 = ( - message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) let interaction: Interaction = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, threadId: threadId, threadVariant: threadVariant, - authorId: caller, + authorId: decodedMessage.sender.hexString, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: messageSentTimestampMs, + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: messageSentTimestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: nil ) }, @@ -498,7 +498,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: timestampMs, + timestampMs: UInt64(timestampMs), openGroupUrlInfo: nil ) }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 871968e0c2..40b05e3608 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -32,7 +32,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: timestampMs, + timestampMs: UInt64(timestampMs), openGroupUrlInfo: nil ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index 80739f5e08..8989c19ff7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -11,18 +11,16 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - proto: SNProtoContent, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + guard proto.hasExpirationType || proto.hasExpirationTimer else { throw MessageError.invalidMessage("Message missing required fields") } - guard - threadVariant == .contact, // Groups are handled via the GROUP_INFO config instead - let sender: String = message.sender, - let timestampMs: UInt64 = message.sentTimestampMs - else { throw MessageError.invalidMessage("Message missing required fields") } + guard threadVariant == .contact else { throw MessageError.invalidMessage("Message type should be handled by config change") } let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) @@ -50,8 +48,8 @@ extension MessageReceiver { return try updatedConfig.insertControlMessage( db, threadVariant: threadVariant, - authorId: sender, - timestampMs: Int64(timestampMs), + authorId: decodedMessage.sender.hexString, + timestampMs: decodedMessage.sentTimestampMs, serverHash: message.serverHash, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies @@ -62,16 +60,20 @@ extension MessageReceiver { _ db: ObservingDatabase, messageVariant: Message.Variant?, contactId: String?, - version: FeatureVersion?, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) { guard let messageVariant: Message.Variant = messageVariant, let contactId: String = contactId, - let version: FeatureVersion = version + [ .visibleMessage, .expirationTimerUpdate ].contains(messageVariant), + let proto: SNProtoContent = try? decodedMessage.decodeProtoContent() else { return } - guard [ .visibleMessage, .expirationTimerUpdate ].contains(messageVariant) else { return } + let version: FeatureVersion = ((!proto.hasExpirationType && !proto.hasExpirationTimer) ? + .legacyDisappearingMessages : + .newDisappearingMessages + ) _ = try? Contact .filter(id: contactId) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index a7db3d119e..27bbe21b87 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -12,6 +12,7 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, suppressNotifications: Bool, using dependencies: Dependencies @@ -21,6 +22,7 @@ extension MessageReceiver { return try MessageReceiver.handleGroupInvite( db, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -29,6 +31,7 @@ extension MessageReceiver { return try MessageReceiver.handleGroupPromotion( db, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -38,6 +41,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -47,6 +51,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -56,6 +61,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, using: dependencies ) return nil @@ -65,6 +71,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies ) @@ -74,6 +81,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, using: dependencies ) return nil @@ -83,6 +91,7 @@ extension MessageReceiver { db, groupSessionId: sessionId, message: message, + decodedMessage: decodedMessage, using: dependencies ) return nil @@ -130,14 +139,10 @@ extension MessageReceiver { private static func handleGroupInvite( _ db: ObservingDatabase, message: GroupUpdateInviteMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } - // Ensure the message is valid try validateGroupInvite(message: message, using: dependencies) @@ -145,10 +150,11 @@ extension MessageReceiver { if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + decodedPro: decodedMessage.decodedPro, profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -157,8 +163,7 @@ extension MessageReceiver { return try processGroupInvite( db, message: message, - sender: sender, - sentTimestampMs: Int64(sentTimestampMs), + decodedMessage: decodedMessage, groupSessionId: message.groupSessionId, groupName: message.groupName, memberAuthData: message.memberAuthData, @@ -232,14 +237,10 @@ extension MessageReceiver { private static func handleGroupPromotion( _ db: ObservingDatabase, message: GroupUpdatePromoteMessage, + decodedMessage: DecodedMessage, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } - let groupIdentityKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate( .ed25519KeyPair(seed: Array(message.groupIdentitySeed)) ) @@ -249,10 +250,11 @@ extension MessageReceiver { if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + decodedPro: decodedMessage.decodedPro, profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -262,8 +264,7 @@ extension MessageReceiver { let insertedInteractionInfo: InsertedInteractionInfo? = try processGroupInvite( db, message: message, - sender: sender, - sentTimestampMs: Int64(sentTimestampMs), + decodedMessage: decodedMessage, groupSessionId: groupSessionId, groupName: message.groupName, memberAuthData: nil, @@ -324,20 +325,17 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInfoChangeMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } guard Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, verificationBytes: GroupUpdateInfoChangeMessage.generateVerificationBytes( changeType: message.changeType, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) @@ -361,13 +359,13 @@ extension MessageReceiver { serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoUpdated, body: message.updatedName .map { ClosedGroup.MessageInfo.updatedName($0) } .defaulting(to: ClosedGroup.MessageInfo.updatedNameFallback) .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -378,12 +376,12 @@ extension MessageReceiver { serverHash: message.serverHash, threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoUpdated, body: ClosedGroup.MessageInfo .updatedDisplayPicture .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -401,8 +399,8 @@ extension MessageReceiver { return try config.insertControlMessage( db, threadVariant: .group, - authorId: sender, - timestampMs: Int64(sentTimestampMs), + authorId: decodedMessage.sender.hexString, + timestampMs: decodedMessage.sentTimestampMs, serverHash: message.serverHash, serverExpirationTimestamp: serverExpirationTimestamp, using: dependencies @@ -418,20 +416,17 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberChangeMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } guard Authentication.verify( signature: message.adminSignature, publicKey: groupSessionId.publicKey, verificationBytes: GroupUpdateMemberChangeMessage.generateVerificationBytes( changeType: message.changeType, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) @@ -501,10 +496,10 @@ extension MessageReceiver { let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupMembersUpdated, body: messageBody, - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -522,14 +517,11 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftMessage, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws { // If the user is a group admin then we need to remove the member from the group, we already have a // "member left" message so `sendMemberChangedMessage` should be `false` - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } guard dependencies.mutate(cache: .libSession, { cache in cache.isAdmin(groupSessionId: groupSessionId) @@ -541,10 +533,10 @@ extension MessageReceiver { MessageSender .removeGroupMembers( groupSessionId: groupSessionId.hexString, - memberIds: [sender], + memberIds: [decodedMessage.sender.hexString], removeTheirMessages: false, sendMemberChangedMessage: false, - changeTimestampMs: Int64(sentTimestampMs), + changeTimestampMs: Int64(decodedMessage.sentTimestampMs), using: dependencies ) .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) @@ -556,14 +548,10 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateMemberLeftNotificationMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } - // Add a record of the specific change to the conversation (the actual change is handled via // config messages so these are only for record purposes) let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo( @@ -575,6 +563,7 @@ extension MessageReceiver { using: dependencies ) + let sender: String = decodedMessage.sender.hexString let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, @@ -589,7 +578,7 @@ extension MessageReceiver { ) ) .infoString(using: dependencies), - timestampMs: Int64(sentTimestampMs), + timestampMs: Int64(decodedMessage.sentTimestampMs), expiresInSeconds: messageExpirationInfo.expiresInSeconds, expiresStartedAtMs: messageExpirationInfo.expiresStartedAtMs, using: dependencies @@ -604,13 +593,9 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateInviteResponseMessage, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws { - guard let sender: String = message.sender else { throw MessageError.missingRequiredField("sender") } - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } - // Only process the invite response if it was an approval guard message.isApproved else { throw MessageError.ignorableMessage } @@ -618,10 +603,11 @@ extension MessageReceiver { if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + decodedPro: decodedMessage.decodedPro, profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -630,12 +616,12 @@ extension MessageReceiver { // Update the member approval state try MessageReceiver.updateMemberApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, groupSessionIdHexString: groupSessionId.hexString, profile: message.profile.map { profile in profile.displayName.map { Profile( - id: sender, + id: decodedMessage.sender.hexString, name: $0, displayPictureUrl: profile.profilePictureUrl, displayPictureEncryptionKey: profile.profileKey, @@ -651,12 +637,9 @@ extension MessageReceiver { _ db: ObservingDatabase, groupSessionId: SessionId, message: GroupUpdateDeleteMemberContentMessage, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws { - guard let sentTimestampMs: UInt64 = message.sentTimestampMs else { - throw MessageError.missingRequiredField("sentTimestampMs") - } - let interactionIdsToRemove: [Int64] let explicitHashesToRemove: [String] let memberSessionIdsContainsSender: Bool = message.memberSessionIds @@ -672,7 +655,7 @@ extension MessageReceiver { verificationBytes: GroupUpdateDeleteMemberContentMessage.generateVerificationBytes( memberSessionIds: message.memberSessionIds, messageHashes: message.messageHashes, - timestampMs: sentTimestampMs + timestampMs: decodedMessage.sentTimestampMs ), using: dependencies ) @@ -682,13 +665,13 @@ extension MessageReceiver { let interactionIdsForRemovedHashes: [Int64] = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .asRequest(of: Int64.self) .fetchAll(db) let interactionIdsSentByRemovedSenders: [Int64] = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(message.memberSessionIds.asSet().contains(Interaction.Columns.authorId)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .asRequest(of: Int64.self) .fetchAll(db) interactionIdsToRemove = interactionIdsForRemovedHashes + interactionIdsSentByRemovedSenders @@ -699,14 +682,14 @@ extension MessageReceiver { interactionIdsToRemove = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .select(.id) .asRequest(of: Int64.self) .fetchAll(db) explicitHashesToRemove = try Interaction .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .filter(Interaction.Columns.serverHash != nil) .select(.serverHash) .asRequest(of: String.self) @@ -718,7 +701,7 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .select(.id) .asRequest(of: Int64.self) .fetchAll(db) @@ -726,7 +709,7 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == groupSessionId.hexString) .filter(Interaction.Columns.authorId == sender) .filter(message.messageHashes.asSet().contains(Interaction.Columns.serverHash)) - .filter(Interaction.Columns.timestampMs < sentTimestampMs) + .filter(Interaction.Columns.timestampMs < decodedMessage.sentTimestampMs) .filter(Interaction.Columns.serverHash != nil) .select(.serverHash) .asRequest(of: String.self) @@ -876,11 +859,10 @@ extension MessageReceiver { // MARK: - Shared - internal static func processGroupInvite( + private static func processGroupInvite( _ db: ObservingDatabase, message: Message, - sender: String, - sentTimestampMs: Int64, + decodedMessage: DecodedMessage, groupSessionId: SessionId, groupName: String, memberAuthData: Data?, @@ -896,7 +878,7 @@ extension MessageReceiver { let inviteSenderIsApproved: Bool = { guard !dependencies[feature: .updatedGroupsDisableAutoApprove] else { return false } - return ((try? Contact.fetchOne(db, id: sender))?.isApproved == true) + return ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isApproved == true) }() let threadAlreadyExisted: Bool = ((try? SessionThread.exists(db, id: groupSessionId.hexString)) ?? false) @@ -911,7 +893,7 @@ extension MessageReceiver { groupIdentityPrivateKey: groupIdentityPrivateKey, name: groupName, authData: memberAuthData, - joinedAt: TimeInterval(Double(sentTimestampMs) / 1000), + joinedAt: TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000), invited: !inviteSenderIsApproved, forceMarkAsInvited: wasKickedFromGroup, using: dependencies @@ -920,7 +902,7 @@ extension MessageReceiver { /// Add the sender as a group admin (so we can retrieve their profile details for Group Message Request UI) try GroupMember( groupId: groupSessionId.hexString, - profileId: sender, + profileId: decodedMessage.sender.hexString, role: .admin, roleStatus: .accepted, isHidden: false @@ -967,10 +949,11 @@ extension MessageReceiver { /// Unline most control messages we don't bother setting expiration values for this message, this is because we won't actually /// have the current disappearing messages config as we won't have polled the group yet (and the settings are stored in the /// `GroupInfo` config) + let sender: String = decodedMessage.sender.hexString let interaction: Interaction = try Interaction( threadId: groupSessionId.hexString, threadVariant: .group, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: .infoGroupInfoInvited, body: { switch groupIdentityPrivateKey { @@ -993,12 +976,12 @@ extension MessageReceiver { .infoString(using: dependencies) } }(), - timestampMs: sentTimestampMs, + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: groupSessionId.hexString, threadVariant: .group, - timestampMs: sentTimestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: nil ) }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 35482a362c..6d691e2c42 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -12,6 +12,7 @@ extension MessageReceiver { internal static func handleMessageRequestResponse( _ db: ObservingDatabase, message: MessageRequestResponse, + decodedMessage: DecodedMessage, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let userSessionId = dependencies[cache: .general].sessionId @@ -29,6 +30,7 @@ extension MessageReceiver { publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), + decodedPro: decodedMessage.decodedPro, profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 645377e4bf..79022450e4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -20,19 +20,11 @@ extension MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: VisibleMessage, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - associatedWithProto proto: SNProtoContent, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo { - guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { - throw MessageError.missingRequiredField - } - - // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to - // seconds to maintain the accuracy) - let messageSentTimestampMs: UInt64 = message.sentTimestampMs ?? 0 - let messageSentTimestamp: TimeInterval = TimeInterval(Double(messageSentTimestampMs) / 1000) let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] // Update profile if needed (want to do this regardless of whether the message exists or @@ -40,10 +32,11 @@ extension MessageReceiver { if let profile = message.profile { try Profile.updateIfNeeded( db, - publicKey: sender, + publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), + decodedPro: decodedMessage.decodedPro, profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -72,7 +65,9 @@ extension MessageReceiver { id: threadId, variant: threadVariant, values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo(messageSentTimestamp), + creationDateTimestamp: .useExistingOrSetTo( + TimeInterval(Double(decodedMessage.sentTimestampMs) / 1000) + ), shouldBeVisible: .useExisting ), using: dependencies @@ -83,24 +78,21 @@ extension MessageReceiver { return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) }() let variant: Interaction.Variant = try { - guard - let senderSessionId: SessionId = try? SessionId(from: sender), - let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo - else { - return (sender == userSessionId.hexString ? + guard let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else { + return (decodedMessage.sender == userSessionId ? .standardOutgoing : .standardIncoming ) } // Need to check if the blinded id matches for open groups - switch senderSessionId.prefix { + switch decodedMessage.sender.prefix { case .blinded15, .blinded25: guard dependencies[singleton: .crypto].verify( .sessionId( userSessionId.hexString, - matchesBlindedId: sender, + matchesBlindedId: decodedMessage.sender.hexString, serverPublicKey: openGroupUrlInfo.publicKey ) ) @@ -109,7 +101,7 @@ extension MessageReceiver { return .standardOutgoing case .standard, .unblinded: - return (sender == userSessionId.hexString ? + return (decodedMessage.sender == userSessionId ? .standardOutgoing : .standardIncoming ) @@ -149,9 +141,7 @@ extension MessageReceiver { db, thread: thread, message: message, - associatedWithProto: proto, - sender: sender, - messageSentTimestamp: messageSentTimestamp, + decodedMessage: decodedMessage, openGroupUrlInfo: openGroupUrlInfo, currentUserSessionIds: generateCurrentUserSessionIds(), suppressNotifications: suppressNotifications, @@ -173,7 +163,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: openGroupUrlInfo ) } @@ -187,10 +177,10 @@ extension MessageReceiver { using: dependencies ) do { - let isProMessage: Bool = dependencies.mutate(cache: .libSession, { $0.validateProProof(for: message) }) - let processedMessageBody: String? = Self.truncateMessageTextIfNeeded( + let processedMessageBody: String? = processMessageBody( message.text, - isProMessage: isProMessage, + decodedMessage: decodedMessage, + threadVariant: thread.variant, dependencies: dependencies ) @@ -198,16 +188,16 @@ extension MessageReceiver { serverHash: message.serverHash, // Keep track of server hash threadId: thread.id, threadVariant: thread.variant, - authorId: sender, + authorId: decodedMessage.sender.hexString, variant: variant, body: processedMessageBody, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: Int64(decodedMessage.sentTimestampMs), wasRead: wasRead, hasMention: Interaction.isUserMentioned( db, threadId: thread.id, body: processedMessageBody, - quoteAuthorId: dataMessage.quote?.author, + quoteAuthorId: message.quote?.authorId, using: dependencies ), expiresInSeconds: messageExpirationInfo.expiresInSeconds, @@ -233,9 +223,9 @@ extension MessageReceiver { variant == .standardOutgoing, let existingInteractionId: Int64 = try? thread.interactions .select(.id) - .filter(Interaction.Columns.timestampMs == (messageSentTimestamp * 1000)) + .filter(Interaction.Columns.timestampMs == decodedMessage.sentTimestampMs) .filter(Interaction.Columns.variant == variant) - .filter(Interaction.Columns.authorId == sender) + .filter(Interaction.Columns.authorId == decodedMessage.sender.hexString) .asRequest(of: Int64.self) .fetchOne(db) else { break } @@ -247,7 +237,7 @@ extension MessageReceiver { db, thread: thread, interactionId: existingInteractionId, - messageSentTimestamp: messageSentTimestamp, + messageSentTimestampMs: decodedMessage.sentTimestampMs, variant: variant, syncTarget: message.syncTarget, using: dependencies @@ -276,7 +266,7 @@ extension MessageReceiver { db, thread: thread, interactionId: interactionId, - messageSentTimestamp: messageSentTimestamp, + messageSentTimestampMs: decodedMessage.sentTimestampMs, variant: variant, syncTarget: message.syncTarget, using: dependencies @@ -303,45 +293,61 @@ extension MessageReceiver { expireInSeconds: message.expiresInSeconds, using: dependencies ) - + // Parse & persist attachments - let attachments: [Attachment] = try dataMessage.attachments - .compactMap { proto -> Attachment? in - let attachment: Attachment = Attachment(proto: proto) - - // Attachments on received messages must have a 'downloadUrl' otherwise - // they are invalid and we can ignore them - return (attachment.downloadUrl != nil ? attachment : nil) - } - .enumerated() - .map { index, attachment in - let savedAttachment: Attachment = try attachment.upserted(db) - - // Link the attachment to the interaction and add to the id lookup - try InteractionAttachment( - albumIndex: index, - interactionId: interactionId, - attachmentId: savedAttachment.id - ).insert(db) - - return savedAttachment - } + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + var attachments: [Attachment] = [] - message.attachmentIds = attachments.map { $0.id } + if + let protoAttachments: [SNProtoAttachmentPointer] = proto.dataMessage?.attachments, + !protoAttachments.isEmpty + { + attachments = try protoAttachments + .compactMap { proto -> Attachment? in + let attachment: Attachment = Attachment(proto: proto) + + // Attachments on received messages must have a 'downloadUrl' otherwise + // they are invalid and we can ignore them + return (attachment.downloadUrl != nil ? attachment : nil) + } + .enumerated() + .map { index, attachment in + let savedAttachment: Attachment = try attachment.upserted(db) + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: savedAttachment.id + ).insert(db) + + return savedAttachment + } + + message.attachmentIds = attachments.map { $0.id } + } // Persist quote if needed - try? Quote( - proto: dataMessage, - interactionId: interactionId, - thread: thread - )?.insert(db) + if let quote: VisibleMessage.VMQuote = message.quote { + try? Quote( + interactionId: interactionId, + authorId: quote.authorId, + timestampMs: Int64(quote.timestamp) + ).insert(db) + } // Parse link preview if needed - let linkPreview: LinkPreview? = try? LinkPreview( - db, - proto: dataMessage, - sentTimestampMs: (messageSentTimestamp * 1000) - )?.upserted(db) + var linkPreviewAttachmentId: String? + if let linkPreview: VisibleMessage.VMLinkPreview = message.linkPreview { + let linkPreview: LinkPreview? = try? LinkPreview( + db, + linkPreview: linkPreview, + sentTimestampMs: decodedMessage.sentTimestampMs + ) + _ = try? linkPreview?.upserted(db) + + linkPreviewAttachmentId = linkPreview?.attachmentId + } // Open group invitations are stored as LinkPreview values so create one if needed if @@ -350,7 +356,7 @@ extension MessageReceiver { { try LinkPreview( url: openGroupInvitationUrl, - timestamp: LinkPreview.timestampFor(sentTimestampMs: (messageSentTimestamp * 1000)), + timestamp: LinkPreview.timestampFor(sentTimestampMs: decodedMessage.sentTimestampMs), variant: .openGroupInvitation, title: openGroupInvitationName, using: dependencies @@ -359,12 +365,12 @@ extension MessageReceiver { // Start attachment downloads if needed (ie. trusted contact or group thread) // FIXME: Replace this to check the `autoDownloadAttachments` flag we are adding to threads - let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) + let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: decodedMessage.sender.hexString))?.isTrusted ?? false) if isContactTrusted || thread.variant != .contact { attachments .map { $0.id } - .appending(linkPreview?.attachmentId) + .appending(linkPreviewAttachmentId) .forEach { attachmentId in dependencies[singleton: .jobRunner].add( db, @@ -401,7 +407,7 @@ extension MessageReceiver { case .contact: try MessageReceiver.updateContactApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, threadId: thread.id, using: dependencies ) @@ -409,7 +415,7 @@ extension MessageReceiver { case .group: try MessageReceiver.updateMemberApprovalStatusIfNeeded( db, - senderSessionId: sender, + senderSessionId: decodedMessage.sender.hexString, groupSessionIdHexString: thread.id, profile: nil, // Don't update the profile in this case using: dependencies @@ -508,18 +514,13 @@ extension MessageReceiver { _ db: ObservingDatabase, thread: SessionThread, message: VisibleMessage, - associatedWithProto proto: SNProtoContent, - sender: String, - messageSentTimestamp: TimeInterval, + decodedMessage: DecodedMessage, openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, currentUserSessionIds: Set, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> Int64? { - guard - let vmReaction: VisibleMessage.VMReaction = message.reaction, - proto.dataMessage?.reaction != nil - else { return nil } + guard let vmReaction: VisibleMessage.VMReaction = message.reaction else { return nil } // Since we have database access here make sure the original message for this reaction exists // before handling it or showing a notification @@ -548,13 +549,12 @@ extension MessageReceiver { // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid // requiring main-thread execution let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) let userSessionId: SessionId = dependencies[cache: .general].sessionId _ = try Reaction( interactionId: interactionId, serverHash: message.serverHash, - timestampMs: timestampMs, - authorId: sender, + timestampMs: Int64(decodedMessage.sentTimestampMs), + authorId: decodedMessage.sender.hexString, emoji: vmReaction.emoji, count: 1, sortId: sortId @@ -563,7 +563,7 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: timestampMs, + timestampMs: decodedMessage.sentTimestampMs, openGroupUrlInfo: openGroupUrlInfo ) } @@ -572,9 +572,9 @@ extension MessageReceiver { // the conversation or the reaction is for the sender's own message if !suppressNotifications && - sender != userSessionId.hexString && + decodedMessage.sender != userSessionId && !timestampAlreadyRead && - vmReaction.publicKey != sender + vmReaction.publicKey != decodedMessage.sender.hexString { try? dependencies[singleton: .notificationsManager].notifyUser( cat: .messageReceiver, @@ -620,7 +620,7 @@ extension MessageReceiver { case .remove: try Reaction .filter(Reaction.Columns.interactionId == interactionId) - .filter(Reaction.Columns.authorId == sender) + .filter(Reaction.Columns.authorId == decodedMessage.sender.hexString) .filter(Reaction.Columns.emoji == vmReaction.emoji) .deleteAll(db) } @@ -632,7 +632,7 @@ extension MessageReceiver { _ db: ObservingDatabase, thread: SessionThread, interactionId: Int64, - messageSentTimestamp: TimeInterval, + messageSentTimestampMs: UInt64, variant: Interaction.Variant, syncTarget: String?, using dependencies: Dependencies @@ -668,7 +668,7 @@ extension MessageReceiver { // Process any PendingReadReceipt values let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt .filter(PendingReadReceipt.Columns.threadId == thread.id) - .filter(PendingReadReceipt.Columns.interactionTimestampMs == Int64(messageSentTimestamp * 1000)) + .filter(PendingReadReceipt.Columns.interactionTimestampMs == messageSentTimestampMs) .fetchOne(db) if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt { @@ -684,24 +684,46 @@ extension MessageReceiver { } } - private static func truncateMessageTextIfNeeded( + private static func processMessageBody( _ text: String?, - isProMessage: Bool, + decodedMessage: DecodedMessage, + threadVariant: SessionThread.Variant, dependencies: Dependencies ) -> String? { - guard let text = text else { return nil } + guard let text: String = text else { return nil } + + /// Extract the features used for the message + let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features(for: text) + let proStatus: SessionPro.ProStatus = dependencies[singleton: .sessionProManager].proStatus( + for: decodedMessage.decodedPro?.proProof, + verifyPubkey: { + switch threadVariant { + case .community: return Array(Data(hex: Network.SessionPro.serverPublicKey)) + default: return decodedMessage.senderEd25519Pubkey + } + }(), + atTimestampMs: decodedMessage.sentTimestampMs + ) + + /// Check if the message is too long + guard + info.status == .exceedsCharacterLimit || ( + proStatus != .valid && + info.features.contains(.largerCharacterLimit) + ) + else { return text } + // FIXME: Replace this with a libSession-based truncation solution let utf16View = text.utf16 - // TODO: Remove after Session Pro is enabled - let offset: Int = (dependencies[feature: .sessionProEnabled] && !isProMessage ? - SessionPro.CharacterLimit : - SessionPro.ProCharacterLimit + let characterLimit: Int = (proStatus == .valid ? + SessionPro.ProCharacterLimit : + SessionPro.CharacterLimit ) - guard utf16View.count > offset else { return text } + guard utf16View.count > characterLimit else { return text } // Get the index at the maxUnits position in UTF16 - let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: offset) + let endUTF16Index = utf16View.index(utf16View.startIndex, offsetBy: characterLimit) // Try converting that UTF16 index back to a String.Index if let endIndex = String.Index(endUTF16Index, within: text) { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 62d086d70f..a791d1c472 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -455,7 +455,7 @@ extension MessageSender { .fetchOne(db) else { throw MessageError.requiresGroupIdentityPrivateKey } - let currentOffsetTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let currentOffsetTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) try dependencies.mutate(cache: .libSession) { cache in diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 9934b7c2c3..e5fccab36d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -44,7 +44,6 @@ public enum MessageReceiver { throw MessageError.invalidRevokedRetrievalMessageHandling } - let proto: SNProtoContent = try SNProtoContent.builder().build() let message: LibSessionMessage = LibSessionMessage(ciphertext: data) message.sender = publicKey /// The "group" sends these messages message.serverHash = serverHash @@ -52,20 +51,19 @@ public enum MessageReceiver { return .standard( threadId: publicKey, threadVariant: .group, - proto: proto, - messageInfo: try MessageReceiveJob.Details.MessageInfo( + messageInfo: MessageReceiveJob.Details.MessageInfo( message: message, variant: .libSessionMessage, threadVariant: .group, serverExpirationTimestamp: serverExpirationTimestamp, - proto: proto + decodedMessage: .empty /// LibSession system message doesn't need a `decodedMessage` ), uniqueIdentifier: serverHash ) } /// For all other cases we can just decode the message - let (proto, sender, sentTimestampMs, decodedProForMessage): (SNProtoContent, String, UInt64) = try dependencies[singleton: .crypto].tryGenerate( + let decodedMessage: DecodedMessage = try dependencies[singleton: .crypto].tryGenerate( .decodedMessage( encodedMessage: data, origin: origin @@ -77,9 +75,10 @@ public enum MessageReceiver { let serverExpirationTimestamp: TimeInterval? let uniqueIdentifier: String let userSessionId: SessionId = dependencies[cache: .general].sessionId - let message: Message = try Message.createMessageFrom(proto, sender: sender, using: dependencies) - message.sender = sender - message.sentTimestampMs = sentTimestampMs + let proto: SNProtoContent = try decodedMessage.decodeProtoContent() + let message: Message = try Message.createMessageFrom(proto, decodedMessage: decodedMessage, using: dependencies) + message.sender = decodedMessage.sender.hexString + message.sentTimestampMs = decodedMessage.sentTimestampMs message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -104,17 +103,17 @@ public enum MessageReceiver { /// Don't process community inbox messages if the sender is blocked guard dependencies.mutate(cache: .libSession, { cache in - !cache.isContactBlocked(contactId: sender) + !cache.isContactBlocked(contactId: decodedMessage.sender.hexString) }) || message.processWithBlockedSender else { throw MessageError.senderBlocked } /// Ignore self sends if needed - guard message.isSelfSendValid || sender != userSessionId.hexString else { + guard message.isSelfSendValid || decodedMessage.sender != userSessionId else { throw MessageError.selfSend } - threadId = sender + threadId = decodedMessage.sender.hexString threadVariant = .contact serverExpirationTimestamp = nil uniqueIdentifier = "\(messageServerId)" @@ -124,13 +123,13 @@ public enum MessageReceiver { /// Don't process 1-to-1 or group messages if the sender is blocked guard dependencies.mutate(cache: .libSession, { cache in - !cache.isContactBlocked(contactId: sender) + !cache.isContactBlocked(contactId: decodedMessage.sender.hexString) }) || message.processWithBlockedSender else { throw MessageError.senderBlocked } /// Ignore self sends if needed - guard message.isSelfSendValid || sender != userSessionId.hexString else { + guard message.isSelfSendValid || decodedMessage.sender != userSessionId else { throw MessageError.selfSend } @@ -138,7 +137,7 @@ public enum MessageReceiver { case .default: threadId = Message.threadId( forMessage: message, - destination: .contact(publicKey: sender), + destination: .contact(publicKey: decodedMessage.sender.hexString), using: dependencies ) threadVariant = .contact @@ -149,7 +148,7 @@ public enum MessageReceiver { default: Log.warn(.messageReceiver, "Couldn't process message due to invalid namespace.") - throw MessageError.unknownMessage(proto) + throw MessageError.unknownMessage(decodedMessage) } serverExpirationTimestamp = expirationTimestamp @@ -164,16 +163,14 @@ public enum MessageReceiver { return .standard( threadId: threadId, threadVariant: threadVariant, - proto: proto, - messageInfo: try MessageReceiveJob.Details.MessageInfo( + messageInfo: MessageReceiveJob.Details.MessageInfo( message: message, variant: try Message.Variant(from: message) ?? { throw MessageError.invalidMessage("Unknown message type: \(type(of: message))") }(), threadVariant: threadVariant, serverExpirationTimestamp: serverExpirationTimestamp, - proto: proto - // TODO: [PRO] Store the pro proof in these details + decodedMessage: decodedMessage ), uniqueIdentifier: uniqueIdentifier ) @@ -186,8 +183,8 @@ public enum MessageReceiver { threadId: String, threadVariant: SessionThread.Variant, message: Message, + decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, - associatedWithProto proto: SNProtoContent, suppressNotifications: Bool, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { @@ -205,12 +202,9 @@ public enum MessageReceiver { MessageReceiver.updateContactDisappearingMessagesVersionIfNeeded( db, - messageVariant: .init(from: message), + messageVariant: Message.Variant(from: message), contactId: message.sender, - version: ((!proto.hasExpirationType && !proto.hasExpirationTimer) ? - .legacyDisappearingMessages : - .newDisappearingMessages - ), + decodedMessage: decodedMessage, using: dependencies ) @@ -244,6 +238,7 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, suppressNotifications: suppressNotifications, using: dependencies @@ -265,8 +260,8 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, - proto: proto, using: dependencies ) @@ -286,6 +281,7 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, using: dependencies ) @@ -294,6 +290,7 @@ public enum MessageReceiver { interactionInfo = try MessageReceiver.handleMessageRequestResponse( db, message: message, + decodedMessage: decodedMessage, using: dependencies ) @@ -303,8 +300,8 @@ public enum MessageReceiver { threadId: threadId, threadVariant: threadVariant, message: message, + decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, - associatedWithProto: proto, suppressNotifications: suppressNotifications, using: dependencies ) @@ -319,7 +316,7 @@ public enum MessageReceiver { using: dependencies ) - default: throw MessageError.unknownMessage(proto) + default: throw MessageError.unknownMessage(decodedMessage) } // Perform any required post-handling logic diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 2d0fe7c8cf..5ce9e2eac2 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -143,7 +143,7 @@ public extension NotificationsManagerType { !cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + timestampMs: (message.sentTimestampMs.map { UInt64($0) } ?? 0), /// Default to unread openGroupUrlInfo: openGroupUrlInfo ) }) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index e330d45b0d..432f479bd4 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -374,7 +374,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { else { /// Individually process non-config messages processedMessages.forEach { processedMessage in - guard case .standard(let threadId, let threadVariant, let proto, let messageInfo, _) = processedMessage else { + guard case .standard(let threadId, let threadVariant, let messageInfo, _) = processedMessage else { return } @@ -384,8 +384,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { threadId: threadId, threadVariant: threadVariant, message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, suppressNotifications: (source == .pushNotification), /// Have already shown using: dependencies ) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 85308f6d8d..e8d480be81 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -30,13 +30,18 @@ public actor SessionProManager: SessionProManagerType { nonisolated private let syncState: SessionProManagerSyncState private var proStatusObservationTask: Task? private var masterKeyPair: KeyPair? - private var rotatingKeyPair: KeyPair? + public var rotatingKeyPair: KeyPair? nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let decodedProForMessageStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.backendUserProStatus == .active } nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } + nonisolated public var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { + syncState.decodedProForMessage + } nonisolated public var currentUserIsPro: AsyncStream { backendUserProStatusStream.stream .map { $0 == .active } @@ -50,6 +55,9 @@ public actor SessionProManager: SessionProManagerType { backendUserProStatusStream.stream } nonisolated public var proProof: AsyncStream { proProofStream.stream } + nonisolated public var decodedProForMessage: AsyncStream { + decodedProForMessageStream.stream + } // MARK: - Initialization @@ -80,6 +88,27 @@ public actor SessionProManager: SessionProManagerType { } } + nonisolated public func proStatus( + for proof: Network.SessionPro.ProProof?, + verifyPubkey: I?, + atTimestampMs timestampMs: UInt64 + ) -> SessionPro.ProStatus { + guard let proof: Network.SessionPro.ProProof else { return .none } + + var cProProof: session_protocol_pro_proof = proof.libSessionValue + let cVerifyPubkey: [UInt8] = (verifyPubkey.map { Array($0) } ?? []) + + return SessionPro.ProStatus( + session_protocol_pro_proof_status( + &cProProof, + cVerifyPubkey, + cVerifyPubkey.count, + timestampMs, + nil + ) + ) + } + nonisolated public func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage { guard let cMessage: [CChar] = message.cString(using: .utf8) else { return SessionPro.FeaturesForMessage.invalidString @@ -182,26 +211,34 @@ public actor SessionProManager: SessionProManagerType { private final class SessionProManagerSyncState { private let lock: NSLock = NSLock() private let _dependencies: Dependencies + private var _rotatingKeyPair: KeyPair? = nil private var _backendUserProStatus: Network.SessionPro.BackendUserProStatus? = nil private var _proProof: Network.SessionPro.ProProof? = nil + private var _decodedProForMessage: SessionPro.DecodedProForMessage? = nil fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } + fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } fileprivate var backendUserProStatus: Network.SessionPro.BackendUserProStatus? { lock.withLock { _backendUserProStatus } } fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } + fileprivate var decodedProForMessage: SessionPro.DecodedProForMessage? { lock.withLock { _decodedProForMessage } } fileprivate init(using dependencies: Dependencies) { self._dependencies = dependencies } fileprivate func update( + rotatingKeyPair: Update = .useExisting, backendUserProStatus: Update = .useExisting, - proProof: Update = .useExisting + proProof: Update = .useExisting, + decodedProForMessage: Update = .useExisting ) { lock.withLock { + self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) self._backendUserProStatus = backendUserProStatus.or(self._backendUserProStatus) self._proProof = proProof.or(self._proProof) + self._decodedProForMessage = decodedProForMessage.or(self._decodedProForMessage) } } } @@ -209,12 +246,21 @@ private final class SessionProManagerSyncState { // MARK: - SessionProManagerType public protocol SessionProManagerType: SessionProUIManagerType { + var rotatingKeyPair: KeyPair? { get } + nonisolated var characterLimit: Int { get } + nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } + nonisolated var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { get } nonisolated var backendUserProStatus: AsyncStream { get } nonisolated var proProof: AsyncStream { get } + nonisolated func proStatus( + for proof: Network.SessionPro.ProProof?, + verifyPubkey: I?, + atTimestampMs timestampMs: UInt64 + ) -> SessionPro.ProStatus nonisolated func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift index 359def4778..c9650dd1d5 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionNetworkingKit public extension SessionPro { - struct DecodedProForMessage: Equatable { + struct DecodedProForMessage: Sendable, Codable, Equatable { let status: SessionPro.ProStatus let proProof: Network.SessionPro.ProProof let features: Features diff --git a/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift index cd731cc239..72f96c1e04 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift @@ -27,4 +27,3 @@ public extension SessionPro { } } } - diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift index 6987bd77f1..b642a2f478 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift @@ -4,14 +4,14 @@ import Foundation import SessionUtil public extension SessionPro { - struct Features: OptionSet, Equatable, Hashable { + struct Features: OptionSet, Sendable, Codable, Equatable, Hashable { public let rawValue: UInt64 public static let none: Features = Features(rawValue: 0) - public static let extendedCharacterLimit: Features = Features(rawValue: 1 << 0) + public static let largerCharacterLimit: Features = Features(rawValue: 1 << 0) public static let proBadge: Features = Features(rawValue: 1 << 1) public static let animatedAvatar: Features = Features(rawValue: 1 << 2) - public static let all: Features = [ extendedCharacterLimit, proBadge, animatedAvatar ] + public static let all: Features = [ largerCharacterLimit, proBadge, animatedAvatar ] var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES { SESSION_PROTOCOL_PRO_FEATURES(rawValue) @@ -28,4 +28,3 @@ public extension SessionPro { } } } - diff --git a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift index 5a1160a6dd..0eb3d7885a 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift @@ -7,7 +7,7 @@ import SessionUtil import SessionUtilitiesKit public extension SessionPro { - enum ProStatus: Sendable, CaseIterable { + enum ProStatus: Sendable, Codable, CaseIterable { case none case invalidProBackendSig case invalidUserSig diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index d3d04379dc..20d21861d1 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -33,30 +33,30 @@ public class DisplayPictureManager { case none case contactRemove - case contactUpdateTo(url: String, key: Data, sessionProProof: Network.SessionPro.ProProof?) + case contactUpdateTo(url: String, key: Data) case currentUserRemove - case currentUserUpdateTo(url: String, key: Data, sessionProProof: Network.SessionPro.ProProof?, isReupload: Bool) + case currentUserUpdateTo(url: String, key: Data, isReupload: Bool) case groupRemove case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) case groupUpdateTo(url: String, key: Data) static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.proProof, fallback: fallback, using: dependencies) + return from(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback, using: dependencies) } public static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, contactProProof: profile.proProof, fallback: fallback, using: dependencies) + return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback, using: dependencies) } - static func from(_ url: String?, key: Data?, contactProProof: Network.SessionPro.ProProof?, fallback: Update, using dependencies: Dependencies) -> Update { + static func from(_ url: String?, key: Data?, fallback: Update, using dependencies: Dependencies) -> Update { guard let url: String = url, let key: Data = key else { return fallback } - return .contactUpdateTo(url: url, key: key, sessionProProof: contactProProof) + return .contactUpdateTo(url: url, key: key) } } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index a762fe8b86..84604d2adc 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -131,6 +131,7 @@ public extension Profile { publicKey: userSessionId.hexString, displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, + decodedPro: dependencies[singleton: .sessionProManager].currentUserCurrentDecodedProForMessage, profileUpdateTimestamp: profileUpdateTimestamp, using: dependencies ) @@ -146,6 +147,7 @@ public extension Profile { displayPictureUpdate: DisplayPictureManager.Update = .none, nicknameUpdate: Update = .useExisting, blocksCommunityMessageRequests: Update = .useExisting, + decodedPro: SessionPro.DecodedProForMessage?, profileUpdateTimestamp: TimeInterval?, cacheSource: CacheSource = .libSession(fallback: .database), suppressUserProfileConfigUpdate: Bool = false, @@ -205,8 +207,8 @@ public extension Profile { db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) } - case (.contactUpdateTo(let url, let key, let proProof), false), - (.currentUserUpdateTo(let url, let key, let proProof, _), true): + case (.contactUpdateTo(let url, let key), false), + (.currentUserUpdateTo(let url, let key, _), true): /// If we have already downloaded the image then we can just directly update the stored profile data (it normally /// wouldn't be updated until after the download completes) let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] @@ -240,7 +242,7 @@ public extension Profile { } } - // TODO: Handle Pro Proof update + // TODO: [PRO] Handle Pro Proof update /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break @@ -295,7 +297,7 @@ public extension Profile { var targetKey: Data? = profile.displayPictureEncryptionKey switch displayPictureUpdate { - case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): + case .contactUpdateTo(let url, let key), .currentUserUpdateTo(let url, let key, _): targetUrl = url targetKey = key @@ -364,7 +366,7 @@ public extension Profile { displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), isReuploadProfilePicture: { switch displayPictureUpdate { - case .currentUserUpdateTo(_, _, _, let isReupload): return isReupload + case .currentUserUpdateTo(_, _, let isReupload): return isReupload default: return false } }() diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 5b43d18892..7b35932bed 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -14,9 +14,56 @@ public extension Log.Category { } public extension Network.SessionPro { -// static func test(using dependencies: Dependencies) throws -> Network.PreparedRequest { -// let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) -// let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + static func test(using dependencies: Dependencies) { + let masterKeyPair: KeyPair = try! dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + let rotatingKeyPair: KeyPair = try! dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + + Task { + // FIXME: Make this async/await when the refactored networking is merged + do { + let addProProofRequest = try? Network.SessionPro.addProPaymentOrGetProProof( + transactionId: "12345678", + masterKeyPair: masterKeyPair, + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + let addProProofResponse = try await addProProofRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + let proProofRequest = try? Network.SessionPro.getProProof( + masterKeyPair: masterKeyPair, + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + let proProofResponse = try await proProofRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + let proStatusRequest = try? Network.SessionPro.getProStatus( + masterKeyPair: masterKeyPair, + using: dependencies + ) + let proStatusResponse = try await proStatusRequest + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 + + await MainActor.run { + let tmp1 = addProProofResponse + let tmp2 = proProofResponse + let tmp3 = proStatusResponse + print("RAWR Test Success") + } + } + catch { + print("RAWR Test Error") + } + } + } + static func addProPaymentOrGetProProof( transactionId: String, masterKeyPair: KeyPair, diff --git a/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift index fb94feb4bf..1a15ad41b8 100644 --- a/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift +++ b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift @@ -7,7 +7,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - enum BackendUserProStatus: Sendable, CaseIterable, CustomStringConvertible { + enum BackendUserProStatus: Sendable, CaseIterable, Equatable, CustomStringConvertible { case neverBeenPro case active case expired diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift index d332789c48..b916612e48 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProProof.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -5,13 +5,24 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct ProProof: Equatable { + struct ProProof: Sendable, Codable, Equatable { let version: UInt8 let genIndexHash: [UInt8] let rotatingPubkey: [UInt8] let expiryUnixTimestampMs: UInt64 let signature: [UInt8] + public var libSessionValue: session_protocol_pro_proof { + var result: session_protocol_pro_proof = session_protocol_pro_proof() + result.version = version + result.set(\.gen_index_hash, to: genIndexHash) + result.set(\.rotating_pubkey, to: rotatingPubkey) + result.expiry_unix_ts_ms = expiryUnixTimestampMs + result.set(\.sig, to: signature) + + return result + } + // MARK: - Initialization public init( @@ -38,4 +49,4 @@ public extension Network.SessionPro { } } -extension session_protocol_pro_proof: @retroactive CAccessible {} +extension session_protocol_pro_proof: @retroactive CMutable & CAccessible {} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 91c9318a4f..b08afda544 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -221,7 +221,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension threadVariant = nil threadDisplayName = nil - case .standard(let threadId, let threadVariantVal, _, let messageInfo, _): + case .standard(let threadId, let threadVariantVal, let messageInfo, _): threadVariant = threadVariantVal threadDisplayName = dependencies.mutate(cache: .libSession) { cache in cache.conversationDisplayName( @@ -265,12 +265,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension data: data ) - case .standard(let threadId, let threadVariant, let proto, let messageInfo, _): + case .standard(let threadId, let threadVariant, let messageInfo, _): try handleStandardMessage( notification, threadId: threadId, threadVariant: threadVariant, - proto: proto, messageInfo: messageInfo ) } @@ -372,7 +371,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo ) throws { /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config @@ -430,7 +428,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: inviteMessage.groupName, memberAuthData: inviteMessage.memberAuthData, groupIdentitySeed: nil, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -454,7 +451,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: promoteMessage.groupName, memberAuthData: nil, groupIdentitySeed: promoteMessage.groupIdentitySeed, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -661,7 +657,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension notification, threadId: threadId, threadVariant: threadVariant, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -677,7 +672,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupName: String, memberAuthData: Data?, groupIdentitySeed: Data?, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? @@ -798,7 +792,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension notification, threadId: groupSessionId.hexString, threadVariant: .group, - proto: proto, messageInfo: messageInfo, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever @@ -876,7 +869,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension !cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (messageInfo.message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + timestampMs: messageInfo.decodedMessage.sentTimestampMs, openGroupUrlInfo: nil /// Communities currently don't support PNs ) }) && @@ -926,7 +919,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadId: String, threadVariant: SessionThread.Variant, - proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? @@ -955,6 +947,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) /// Try to show a notification for the message + let proto: SNProtoContent = try messageInfo.decodedMessage.decodeProtoContent() try dependencies[singleton: .notificationsManager].notifyUser( cat: .cat, message: messageInfo.message, diff --git a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift index b591548c98..929b82312e 100644 --- a/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift +++ b/SessionUtilitiesKit/LibSession/Utilities/TypeConversion+Utilities.swift @@ -207,6 +207,9 @@ public extension CAccessible { func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } + func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } + func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } + func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } func get(_ keyPath: KeyPath) -> Data { withUnsafePointer(to: self) { $0.get(keyPath) } } func get(_ keyPath: KeyPath) -> [UInt8] { withUnsafePointer(to: self) { $0.get(keyPath) } } func getHex(_ keyPath: KeyPath) -> String { withUnsafePointer(to: self) { $0.getHex(keyPath) } } @@ -247,6 +250,15 @@ public extension CAccessible { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + withUnsafePointer(to: self) { $0.getHex(keyPath, nullIfEmpty: nullIfEmpty) } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { withUnsafePointer(to: self) { $0.get(keyPath, nullIfEmpty: nullIfEmpty) } } @@ -386,6 +398,9 @@ public extension ReadablePointer { func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } + func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } + func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } + func getHex(_ keyPath: KeyPath) -> String { getData(keyPath).toHexString() } func get(_ keyPath: KeyPath) -> Data { getData(keyPath) } func get(_ keyPath: KeyPath) -> [UInt8] { Array(getData(keyPath)) } @@ -427,6 +442,15 @@ public extension ReadablePointer { func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { + getData(keyPath, nullIfEmpty: nullIfEmpty) + } + func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> [UInt8]? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { Array($0) } + } + func getHex(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> String? { + getData(keyPath, nullIfEmpty: nullIfEmpty).map { $0.toHexString() } + } func get(_ keyPath: KeyPath, nullIfEmpty: Bool = false) -> Data? { getData(keyPath, nullIfEmpty: nullIfEmpty) } @@ -486,12 +510,24 @@ private extension ReadablePointer { return withUnsafeBytes(of: byteArray) { Data($0) } } + private func _getData(_ span: span_u8) -> Data { + guard let data: UnsafeMutablePointer = span.data else { return Data() } + + return Data(bytes: data, count: span.size) + } + func _getData(_ byteArray: T, nullIfEmpty: Bool) -> Data? { let result: Data = _getData(byteArray) return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) } + func _getData(_ span: span_u8, nullIfEmpty: Bool) -> Data? { + let result: Data = _getData(span) + + return (!nullIfEmpty || result.contains(where: { $0 != 0 }) ? result : nil) + } + func _string(from value: T, explicitLength: Int? = nil) -> String { withUnsafeBytes(of: value) { rawBufferPointer in guard let buffer = rawBufferPointer.baseAddress?.assumingMemoryBound(to: CChar.self) else { @@ -511,10 +547,18 @@ private extension ReadablePointer { return _getData(ptr[keyPath: keyPath]) } + func getData(_ keyPath: KeyPath) -> Data { + return _getData(ptr[keyPath: keyPath]) + } + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { return _getData(ptr[keyPath: keyPath], nullIfEmpty: nullIfEmpty) } + func getData(_ keyPath: KeyPath, nullIfEmpty: Bool) -> Data? { + return _getData(ptr[keyPath: keyPath], nullIfEmpty: nullIfEmpty) + } + func getData(_ keyPath: KeyPath) -> Data { return _getData(ptr[keyPath: keyPath].data) } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 1a46c732b8..58cd341983 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -647,7 +647,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.approvalDelegate?.attachmentApprovalDidCancel(self) } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { + @MainActor func showModalForMessagesExceedingCharacterLimit() { let didShowCTAModal: Bool = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in @@ -732,7 +732,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { for: text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: isSessionPro) + showModalForMessagesExceedingCharacterLimit() return } From f32d8a7e48baa5f973ef1b1fea2fc3613401580c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Nov 2025 16:36:36 +1100 Subject: [PATCH 14/66] Populate and update the pro state from the UserProfile config --- Session.xcodeproj/project.pbxproj | 4 +++ .../Crypto/Crypto+LibSession.swift | 2 +- .../LibSession+UserProfile.swift | 17 +++++++++++ .../LibSession+SessionMessagingKit.swift | 2 ++ .../MessageReceiver+VisibleMessages.swift | 2 +- .../SessionPro/SessionProManager.swift | 29 ++++++++++++++++++- .../SessionPro/Types/SessionProConfig.swift | 20 +++++++++++++ .../SessionPro/SessionPro.swift | 11 ++++++- .../SessionPro/SessionProAPI.swift | 9 ++++-- .../Types/Request+SessionProAPI.swift | 5 ++-- 10 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProConfig.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 776b6e36d8..36245bca8c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -956,6 +956,7 @@ FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A12EBAA6A500E59F94 /* Envelope.swift */; }; FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; + FD99A3A82EBB0EE500E59F94 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -2259,6 +2260,7 @@ FD99A3A12EBAA6A500E59F94 /* Envelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Envelope.swift; sourceTree = ""; }; FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; + FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -5065,6 +5067,7 @@ FDAA36C42EB474B50040603E /* Types */ = { isa = PBXGroup; children = ( + FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */, FDAA36C92EB476060040603E /* SessionProFeatures.swift */, @@ -6882,6 +6885,7 @@ FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, + FD99A3A82EBB0EE500E59F94 /* SessionProConfig.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, FD2272772C32911C004D8A6C /* AttachmentUploadJob.swift in Sources */, FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */, diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 3e12111be2..fd2965bc60 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -133,7 +133,7 @@ public extension Crypto.Generator { args: [] ) { dependencies in let cEncodedMessage: [UInt8] = Array(encodedMessage) - let cBackendPubkey: [UInt8] = Array(Data(hex: Network.SessionPro.serverPublicKey)) + let cBackendPubkey: [UInt8] = Array(Data(hex: Network.SessionPro.serverEdPublicKey)) let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var error: [CChar] = [CChar](repeating: 0, count: 256) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index ac92ba0e5c..bfab7c967a 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - LibSession @@ -141,6 +142,11 @@ internal extension LibSessionCacheType { db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } + + // Update the SessionProManager with these changes + db.afterCommit { [sessionProManager = dependencies[singleton: .sessionProManager]] in + Task { await sessionProManager.updateWithLatestFromUserConfig() } + } } } @@ -212,6 +218,17 @@ public extension LibSession.Cache { return String(cString: profileNamePtr) } + var proConfig: SessionPro.ProConfig? { + var cProConfig: pro_pro_config = pro_pro_config() + + guard + case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), + user_profile_get_pro_config(conf, &cProConfig) + else { return nil } + + return SessionPro.ProConfig(cProConfig) + } + func updateProfile( displayName: Update, displayPictureUrl: Update, diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 4dd9e6be6b..66cb2cff5f 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1075,6 +1075,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func set(_ key: Setting.EnumKey, _ value: T?) var displayName: String? { get } + var proConfig: SessionPro.ProConfig? { get } func updateProfile( displayName: Update, @@ -1355,6 +1356,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { // MARK: - State Access var displayName: String? { return nil } + var proConfig: SessionPro.ProConfig? { return nil } func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 79022450e4..ed1fe48885 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -698,7 +698,7 @@ extension MessageReceiver { for: decodedMessage.decodedPro?.proProof, verifyPubkey: { switch threadVariant { - case .community: return Array(Data(hex: Network.SessionPro.serverPublicKey)) + case .community: return Array(Data(hex: Network.SessionPro.serverEdPublicKey)) default: return decodedMessage.senderEd25519Pubkey } }(), diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index e8d480be81..00f309e850 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -66,7 +66,10 @@ public actor SessionProManager: SessionProManagerType { self.syncState = SessionProManagerSyncState(using: dependencies) self.masterKeyPair = dependencies[singleton: .crypto].generate(.sessionProMasterKeyPair()) - Task { await startProStatusObservations() } + Task { + await updateWithLatestFromUserConfig() + await startProStatusObservations() + } } deinit { @@ -123,6 +126,29 @@ public actor SessionProManager: SessionProManagerType { ) } + public func updateWithLatestFromUserConfig() async { + let proConfig: SessionPro.ProConfig? = dependencies.mutate(cache: .libSession) { $0.proConfig } + + let rotatingKeyPair: KeyPair? = try? proConfig.map { config in + guard config.rotatingPrivateKey.count >= 32 else { return nil } + + return try dependencies[singleton: .crypto].tryGenerate( + .ed25519KeyPair(seed: config.rotatingPrivateKey.prefix(upTo: 32)) + ) + } + + /// Update the `syncState` first (just in case an update triggered from the async state results in something accessing the + /// sync state) + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + proProof: .set(to: proConfig?.proProof) + ) + + /// Then update the async state and streams + self.rotatingKeyPair = rotatingKeyPair + await self.proProofStream.send(proConfig?.proProof) + } + public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .active) await backendUserProStatusStream.send(.active) @@ -262,6 +288,7 @@ public protocol SessionProManagerType: SessionProUIManagerType { atTimestampMs timestampMs: UInt64 ) -> SessionPro.ProStatus nonisolated func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage + func updateWithLatestFromUserConfig() async } public extension SessionProManagerType { diff --git a/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift new file mode 100644 index 0000000000..7a4d842592 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift @@ -0,0 +1,20 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct ProConfig { + let rotatingPrivateKey: [UInt8] + let proProof: Network.SessionPro.ProProof + + init(_ libSessionValue: pro_pro_config) { + rotatingPrivateKey = libSessionValue.get(\.rotating_privkey) + proProof = Network.SessionPro.ProProof(libSessionValue.proof) + } + } +} + +extension pro_pro_config: @retroactive CAccessible {} diff --git a/SessionNetworkingKit/SessionPro/SessionPro.swift b/SessionNetworkingKit/SessionPro/SessionPro.swift index 1a1f2ef3be..0c3bf959e1 100644 --- a/SessionNetworkingKit/SessionPro/SessionPro.swift +++ b/SessionNetworkingKit/SessionPro/SessionPro.swift @@ -3,11 +3,20 @@ // stringlint:disable import Foundation +import SessionUtilitiesKit public extension Network { enum SessionPro { public static let apiVersion: UInt8 = 0 static let server = "{NEED_TO_SET}" - static let serverPublicKey = "{NEED_TO_SET}" + public static let serverEdPublicKey = "{NEED_TO_SET}" + + internal static func x25519PublicKey(using dependencies: Dependencies) throws -> String { + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: serverEdPublicKey))) + ) + + return x25519Pubkey.toHexString() + } } } diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 7b35932bed..e4f6c4dff0 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -98,7 +98,8 @@ public extension Network.SessionPro { paymentId: transactionId ), signatures: signatures - ) + ), + using: dependencies ), responseType: AddProPaymentOrGetProProofResponse.self, using: dependencies @@ -133,7 +134,8 @@ public extension Network.SessionPro { rotatingPublicKey: rotatingKeyPair.publicKey, timestampMs: timestampMs, signatures: signatures - ) + ), + using: dependencies ), responseType: AddProPaymentOrGetProProofResponse.self, using: dependencies @@ -166,7 +168,8 @@ public extension Network.SessionPro { timestampMs: timestampMs, count: count, signature: signature - ) + ), + using: dependencies ), responseType: GetProStatusResponse.self, using: dependencies diff --git a/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift index cdec321316..d75da90e94 100644 --- a/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/Types/Request+SessionProAPI.swift @@ -9,7 +9,8 @@ public extension Request where Endpoint == Network.SessionPro.Endpoint { endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String] = [:], - body: T? = nil + body: T? = nil, + using dependencies: Dependencies ) throws { self = try Request( endpoint: endpoint, @@ -18,7 +19,7 @@ public extension Request where Endpoint == Network.SessionPro.Endpoint { server: Network.SessionPro.server, queryParameters: queryParameters, headers: headers, - x25519PublicKey: Network.SessionPro.serverPublicKey + x25519PublicKey: Network.SessionPro.x25519PublicKey(using: dependencies) ), body: body ) From 6bcd3f43a64289e2fe8b304cd3635be3282c4c3a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 13 Nov 2025 14:26:38 +1100 Subject: [PATCH 15/66] Refactoring to move pro state management to VMs instead of views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Refactored the MessageViewModel to be a dumb data types instead of a monolithic query • Refactored the ConversationViewModel due to handle the updated MessageViewModel • Refactored OWSAudioPlayer into Swift • Refactored the OpenGroupManager and OpenGroupManagerCache to be a single actor (CommunityManager), it also holds community state in memory • Removed a bunch of GRDB-specific relationship code (just makes things messy since we have raw SQL elsewhere) • Fixed a bug where the ObservationBuilder could incorrectly run queries in cancelled contexts resulting in potential buggy outputs • Fixed a bug where outgoing voice messages might not be able to be played --- Session.xcodeproj/project.pbxproj | 41 +- .../Calls/Call Management/SessionCall.swift | 4 +- .../Call Management/SessionCallManager.swift | 2 +- Session/Closed Groups/NewClosedGroupVC.swift | 16 +- .../Context Menu/ContextMenuVC+Action.swift | 44 +- .../ConversationVC+Interaction.swift | 958 ++++--- Session/Conversations/ConversationVC.swift | 736 ++--- .../Conversations/ConversationViewModel.swift | 2404 ++++++++++------- .../ExpandingAttachmentsButton.swift | 2 +- .../Conversations/Input View/InputView.swift | 4 +- .../Content Views/MediaPlaceholderView.swift | 2 +- .../Content Views/QuoteView.swift | 35 +- .../SwiftUI/QuoteView_SwiftUI.swift | 35 +- .../Message Cells/InfoMessageCell.swift | 7 +- .../Message Cells/VisibleMessageCell.swift | 54 +- ...isappearingMessagesSettingsViewModel.swift | 6 + .../Settings/ThreadSettingsViewModel.swift | 94 +- .../ConversationTitleView.swift | 77 +- .../Views & Modals/ReactionListSheet.swift | 36 +- .../GlobalSearchViewController.swift | 77 +- Session/Home/HomeVC.swift | 7 +- Session/Home/HomeViewModel.swift | 11 +- .../MessageRequestsViewModel.swift | 12 +- .../New Conversation/NewMessageScreen.swift | 18 +- .../MessageInfoScreen.swift | 411 +-- .../PhotoCapture.swift | 10 +- Session/Meta/AppDelegate.swift | 10 +- Session/Meta/SessionApp.swift | 122 +- .../NotificationActionHandler.swift | 16 +- .../Notifications/NotificationPresenter.swift | 14 +- Session/Open Groups/JoinOpenGroupVC.swift | 30 +- .../Open Groups/OpenGroupSuggestionGrid.swift | 163 +- .../DeveloperSettingsProViewModel.swift | 97 +- .../Settings/NotificationSoundViewModel.swift | 10 +- Session/Settings/NukeDataModal.swift | 1 - Session/Settings/QRCodeScreen.swift | 16 +- Session/Settings/SettingsViewModel.swift | 2 +- .../Views/ThemeMessagePreviewView.swift | 8 +- Session/Shared/BaseVC.swift | 23 +- Session/Shared/UserListViewModel.swift | 49 +- Session/Utilities/BackgroundPoller.swift | 16 +- Session/Utilities/MockDataGenerator.swift | 2 +- .../UIContextualAction+Utilities.swift | 8 +- SessionMessagingKit/Configuration.swift | 3 +- .../_006_SMK_InitialSetupMigration.swift | 2 +- .../_047_DropUnneededColumnsAndTables.swift | 37 + .../Database/Models/Attachment.swift | 10 +- .../Database/Models/ClosedGroup.swift | 33 - .../Database/Models/Contact.swift | 21 + .../DisappearingMessageConfiguration.swift | 12 +- .../Database/Models/Interaction.swift | 211 +- .../Models/InteractionAttachment.swift | 16 +- .../Database/Models/LinkPreview.swift | 18 +- .../Database/Models/OpenGroup.swift | 64 +- .../Database/Models/Quote.swift | 2 +- .../Database/Models/Reaction.swift | 2 +- .../Database/Models/SessionThread.swift | 82 +- .../Models/ThreadTypingIndicator.swift | 30 - .../Jobs/DisplayPictureDownloadJob.swift | 45 - .../Jobs/GarbageCollectionJob.swift | 73 +- .../RetrieveDefaultOpenGroupRoomsJob.swift | 192 +- .../Config Handling/LibSession+Contacts.swift | 9 +- .../LibSession+GroupInfo.swift | 15 +- .../LibSession+GroupMembers.swift | 43 +- .../LibSession+UserGroups.swift | 4 +- .../LibSession/Types/OpenGroupUrlInfo.swift | 10 +- SessionMessagingKit/Messages/Message.swift | 6 +- .../Meta/SessionMessagingKit.h | 1 - ...upManager.swift => CommunityManager.swift} | 1016 ++++--- .../Open Groups/Types/PendingChange.swift | 4 +- .../Open Groups/Types/Server.swift | 200 ++ .../Link Previews/LinkPreviewDraft.swift | 2 +- .../MessageReceiver+MessageRequests.swift | 21 +- .../MessageReceiver+VisibleMessages.swift | 31 +- .../Sending & Receiving/MessageReceiver.swift | 32 +- .../MessageSender+Convenience.swift | 10 +- .../Pollers/CommunityPoller.swift | 81 +- .../Quotes/QuotedReplyModel.swift | 21 +- .../Typing Indicators/TypingIndicators.swift | 38 +- .../SessionPro/SessionProManager.swift | 17 +- .../MessageViewModel+DeletionActions.swift | 23 +- .../Shared Models/MessageViewModel.swift | 1646 ++++------- .../Shared Models/Position.swift | 2 +- .../SessionThreadViewModel.swift | 160 +- .../Utilities/AttachmentManager.swift | 37 +- .../Authentication+SessionMessagingKit.swift | 4 +- .../Utilities/DeviceSleepManager.swift | 16 - .../Utilities/DisplayPictureManager.swift | 48 +- .../Utilities/OWSAudioPlayer.h | 58 - .../Utilities/OWSAudioPlayer.m | 233 -- .../Utilities/OWSAudioPlayer.swift | 257 ++ .../Utilities/OWSAudioSession.swift | 39 +- .../ObservableKey+SessionMessagingKit.swift | 85 +- .../ObservableKeyEvent+Utilities.swift | 13 + .../Utilities/Preferences+Sound.swift | 12 +- .../Utilities/SessionEnvironment.swift | 4 - SessionNetworkingKit/SOGS/Models/Room.swift | 93 +- .../SOGS/Models/SOGSMessage.swift | 4 +- SessionNetworkingKit/SOGS/SOGS.swift | 9 + SessionShareExtension/ThreadPickerVC.swift | 4 +- SessionTests/Database/DatabaseSpec.swift | 3 +- .../Themes/ThemedAttributedString.swift | 85 +- SessionUIKit/Utilities/MentionUtilities.swift | 16 +- .../Database/Types/FetchablePair.swift | 17 + .../Database/Types/PagedData.swift | 67 +- .../Types/PagedDatabaseObserver.swift | 39 + .../LibSession/Types/ObservingDatabase.swift | 10 + .../Observations/DebounceTaskManager.swift | 16 +- .../Observations/ObservationBuilder.swift | 2 +- .../Observations/ObservationManager.swift | 36 +- .../Utilities/ArraySection+Utilities.swift | 17 + SignalUtilitiesKit/Utilities/AppSetup.swift | 6 +- 112 files changed, 5940 insertions(+), 5225 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift delete mode 100644 SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift rename SessionMessagingKit/Open Groups/{OpenGroupManager.swift => CommunityManager.swift} (50%) create mode 100644 SessionMessagingKit/Open Groups/Types/Server.swift delete mode 100644 SessionMessagingKit/Utilities/OWSAudioPlayer.h delete mode 100644 SessionMessagingKit/Utilities/OWSAudioPlayer.m create mode 100644 SessionMessagingKit/Utilities/OWSAudioPlayer.swift create mode 100644 SessionUtilitiesKit/Database/Types/FetchablePair.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index c4ed46a006..a9d234ab47 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -254,7 +254,6 @@ B877E24226CA12910007970A /* CallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24126CA12910007970A /* CallVC.swift */; }; B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; - B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF281255B6D84007E1867 /* OWSAudioSession.swift */; }; B8856D23256F116B001CE70E /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EF255B6DBB007E1867 /* Weak.swift */; }; @@ -454,7 +453,6 @@ FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; - FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77AF29B69A65009169BA /* TopBannerController.swift */; }; FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */; }; @@ -582,12 +580,11 @@ FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; - FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; + FD245C55285065E500B966DD /* CommunityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; }; FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */; }; FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; - FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */; }; FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF306255B6DBE007E1867 /* OWSWindowManager.m */; }; FD245C632850664600B966DD /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD245C612850664300B966DD /* Configuration.swift */; }; FD245C682850666300B966DD /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; }; @@ -964,6 +961,11 @@ FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; FD99A3A82EBB0EE500E59F94 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */; }; + FD99A3AA2EBBF20100E59F94 /* ArraySection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */; }; + FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AB2EBC1B6C00E59F94 /* Server.swift */; }; + FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */; }; + FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */; }; + FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -1764,8 +1766,6 @@ C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+Utilities.swift"; path = "SignalUtilitiesKit/Utilities/UIViewController+Utilities.swift"; sourceTree = SOURCE_ROOT; }; C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ProximityMonitoringManager.swift; path = SessionMessagingKit/Utilities/ProximityMonitoringManager.swift; sourceTree = SOURCE_ROOT; }; C38EF2EF255B6DBB007E1867 /* Weak.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Weak.swift; path = SessionUtilitiesKit/General/Weak.swift; sourceTree = SOURCE_ROOT; }; - C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; - C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAudioPlayer.m; path = SessionMessagingKit/Utilities/OWSAudioPlayer.m; sourceTree = SOURCE_ROOT; }; C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; @@ -1832,7 +1832,7 @@ C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = spanish.txt; sourceTree = ""; }; C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundPoller.swift; sourceTree = ""; }; C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRCopyableLabel.swift; sourceTree = ""; }; - C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; + C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityManager.swift; sourceTree = ""; }; D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; D221A089169C9E5E00537ABF /* Session.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Session.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1889,7 +1889,6 @@ FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; - FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; FD0B77AF29B69A65009169BA /* TopBannerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBannerController.swift; sourceTree = ""; }; FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUtilitiesSpec.swift; sourceTree = ""; }; @@ -2273,6 +2272,11 @@ FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; + FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArraySection+Utilities.swift"; sourceTree = ""; }; + FD99A3AB2EBC1B6C00E59F94 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; + FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchablePair.swift; sourceTree = ""; }; + FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSAudioPlayer.swift; sourceTree = ""; }; + FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _047_DropUnneededColumnsAndTables.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -3746,7 +3750,7 @@ children = ( FD23CE202A661CE80000B97C /* Crypto */, FDC4381827B34EAD00C60D73 /* Types */, - C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, + C3DB66AB260ACA42001EFC55 /* CommunityManager.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -3766,8 +3770,7 @@ C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */, FD1A55422E179AE6003761E4 /* ObservableKeyEvent+Utilities.swift */, - C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, - C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, + FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */, @@ -4064,6 +4067,7 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( + FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */, FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */, 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, @@ -4116,7 +4120,6 @@ FD09799827FFC1A300936362 /* Attachment.swift */, FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, - FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, FD981BC32DC304E100564172 /* MessageDeduplication.swift */, FD5C7308285007920029977D /* BlindedIdLookup.swift */, FD09B7E6288670FD00ED0B66 /* Reaction.swift */, @@ -4218,6 +4221,7 @@ 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */, + FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */, ); path = Migrations; sourceTree = ""; @@ -4234,6 +4238,7 @@ isa = PBXGroup; children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, + FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, @@ -5156,6 +5161,7 @@ isa = PBXGroup; children = ( 7B81682B28B72F480069F315 /* PendingChange.swift */, + FD99A3AB2EBC1B6C00E59F94 /* Server.swift */, ); path = Types; sourceTree = ""; @@ -5509,7 +5515,6 @@ files = ( C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */, B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */, - B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6461,7 +6466,6 @@ FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */, - FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, @@ -6745,6 +6749,7 @@ FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */, FD0E353C2AB9880B006A81F7 /* AppVersion.swift in Sources */, FD7F745F2BAAA3B4006DDFD8 /* TypeConversion+Utilities.swift in Sources */, + FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */, FDE755062C9BB4EE002A2623 /* Bencode.swift in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */, @@ -6774,6 +6779,7 @@ FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, + FD99A3AA2EBBF20100E59F94 /* ArraySection+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */, @@ -6875,6 +6881,7 @@ C300A5F22554B09800555489 /* MessageSender.swift in Sources */, FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */, + FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */, FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */, FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */, FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, @@ -6896,6 +6903,7 @@ FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */, FD2272FC2C352D8E004D8A6C /* LibSession+Contacts.swift in Sources */, + FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */, FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, FD05593D2DFA3A2800DC48CE /* VoipPayloadKey.swift in Sources */, FD2272782C32911C004D8A6C /* AttachmentDownloadJob.swift in Sources */, @@ -6966,7 +6974,6 @@ B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, FD8ECF7D2934293A00C0D1BB /* _027_SessionUtilChanges.swift in Sources */, FD17D7A227F40F0500122BE0 /* _006_SMK_InitialSetupMigration.swift in Sources */, - FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FD2272FE2C352D8E004D8A6C /* LibSession+GroupMembers.swift in Sources */, FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */, @@ -6988,10 +6995,11 @@ FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, FDD23AEB2E458F4D0057E853 /* _020_AddJobUniqueHash.swift in Sources */, FDB11A502DCC6ADE00BEF49F /* ThreadUpdateInfo.swift in Sources */, + FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, - FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */, + FD245C55285065E500B966DD /* CommunityManager.swift in Sources */, FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */, @@ -7054,7 +7062,6 @@ FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, - FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, FDB5DADA2A95D839002C8721 /* GroupUpdateInfoChangeMessage.swift in Sources */, FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */, FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index f08656626c..233deaaa72 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -218,7 +218,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let webRTCSession: WebRTCSession = self.webRTCSession let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let disappearingMessagesConfiguration = try? thread.disappearingMessagesConfiguration.fetchOne(db)?.forcedWithDisappearAfterReadIfNeeded() + let disappearingMessagesConfiguration = try? DisappearingMessagesConfiguration + .fetchOne(db, id: thread.id)? + .forcedWithDisappearAfterReadIfNeeded() let message: CallMessage = CallMessage( uuid: self.uuid, kind: .preOffer, diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 2d7f8eb9dc..85cc971698 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -235,7 +235,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { if let conversationVC: ConversationVC = currentFrontMostViewController as? ConversationVC, - conversationVC.viewModel.threadData.threadId == call.sessionId + conversationVC.viewModel.state.threadId == call.sessionId { let callVC = CallVC(for: call, using: dependencies) callVC.conversationVC = conversationVC diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 37faac7b3e..714d3316ea 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -439,15 +439,13 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` /// and want the transition to be animated in order to behave nicely - await MainActor.run { [weak self, dependencies] in - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: thread.id, - variant: thread.variant, - action: .none, - dismissing: (self?.presentingViewController ?? indicator), - animated: (self?.presentingViewController == nil) - ) - } + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: thread.id, + variant: thread.variant, + action: .none, + dismissing: (self.presentingViewController ?? indicator), + animated: (self.presentingViewController == nil) + ) } catch { await MainActor.run { [weak self] in diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index a424718daa..ac7636c169 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -184,6 +184,8 @@ extension ContextMenuVC { static func actions( for cellViewModel: MessageViewModel, in threadViewModel: SessionThreadViewModel, + reactionsSupported: Bool, + isUserModeratorOrAdmin: Bool, forMessageInfoScreen: Bool, delegate: ContextMenuActionDelegate?, using dependencies: Dependencies @@ -217,18 +219,18 @@ extension ContextMenuVC { cellViewModel.cellType == .genericAttachment || cellViewModel.cellType == .mediaMessage ) && - (cellViewModel.attachments ?? []).count == 1 && - (cellViewModel.attachments ?? []).first?.isVisualMedia == true && - (cellViewModel.attachments ?? []).first?.isValid == true && ( - (cellViewModel.attachments ?? []).first?.state == .downloaded || - (cellViewModel.attachments ?? []).first?.state == .uploaded + cellViewModel.attachments.count == 1 && + cellViewModel.attachments.first?.isVisualMedia == true && + cellViewModel.attachments.first?.isValid == true && ( + cellViewModel.attachments.first?.state == .downloaded || + cellViewModel.attachments.first?.state == .uploaded ) ) ) let canSave: Bool = { switch cellViewModel.cellType { case .mediaMessage: - return (cellViewModel.attachments ?? []) + return cellViewModel.attachments .filter { attachment in attachment.isValid && attachment.isVisualMedia && ( @@ -238,7 +240,7 @@ extension ContextMenuVC { }.isEmpty == false case .audio, .genericAttachment: - return (cellViewModel.attachments ?? []) + return cellViewModel.attachments .filter { attachment in attachment.isValid && ( attachment.state == .downloaded || @@ -255,35 +257,17 @@ extension ContextMenuVC { ) let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], - with: threadViewModel, + threadData: threadViewModel, + isUserModeratorOrAdmin: isUserModeratorOrAdmin, using: dependencies ) != nil) let canBan: Bool = ( cellViewModel.threadVariant == .community && - dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: threadViewModel.currentUserSessionId, - for: threadViewModel.openGroupRoomToken, - on: threadViewModel.openGroupServer, - currentUserSessionIds: (threadViewModel.currentUserSessionIds ?? []) - ) + isUserModeratorOrAdmin ) - let shouldShowEmojiActions: Bool = { - guard cellViewModel.threadVariant != .legacyGroup else { return false } - - if cellViewModel.threadVariant == .community { - return ( - !forMessageInfoScreen && - dependencies[singleton: .openGroupManager].doesOpenGroupSupport( - capability: .reactions, - on: cellViewModel.threadOpenGroupServer - ) - ) - } - return (threadViewModel.threadIsMessageRequest != true && !forMessageInfoScreen) - }() let recentEmojis: [EmojiWithSkinTones] = { - guard shouldShowEmojiActions else { return [] } + guard reactionsSupported else { return [] } return (threadViewModel.recentReactionEmoji ?? []) .compactMap { EmojiWithSkinTones(rawValue: $0) } @@ -300,7 +284,7 @@ extension ContextMenuVC { (forMessageInfoScreen ? nil : Action.info(cellViewModel, delegate)), ] .appending( - contentsOf: (shouldShowEmojiActions ? recentEmojis : []) + contentsOf: (reactionsSupported ? recentEmojis : []) .map { Action.react(cellViewModel, $0, delegate) } ) .appending(forMessageInfoScreen ? nil : Action.emojiPlusButton(cellViewModel, delegate)) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fdcd6f3b83..062efe0885 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -29,7 +29,7 @@ extension ConversationVC: @MainActor @objc func handleTitleViewTapped() { // Don't take the user to settings for unapproved threads - guard viewModel.threadData.threadRequiresApproval == false else { return } + guard viewModel.state.threadViewModel.threadRequiresApproval == false else { return } openSettingsFromTitleView() } @@ -41,13 +41,13 @@ extension ConversationVC: @MainActor func openSettingsFromTitleView() { // If we shouldn't be able to access settings then disable the title view shortcuts - guard viewModel.threadData.canAccessSettings(using: viewModel.dependencies) else { return } + guard viewModel.state.threadViewModel.canAccessSettings(using: viewModel.dependencies) else { return } - switch (titleView.currentLabelType, viewModel.threadData.threadVariant, viewModel.threadData.currentUserIsClosedGroupMember, viewModel.threadData.currentUserIsClosedGroupAdmin) { + switch (titleView.currentLabelType, viewModel.state.threadVariant, viewModel.state.threadViewModel.currentUserIsClosedGroupMember, viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin) { case (.userCount, .group, _, true), (.userCount, .legacyGroup, _, true): let viewController = SessionTableViewController( viewModel: EditGroupViewModel( - threadId: self.viewModel.threadData.threadId, + threadId: self.viewModel.state.threadId, using: self.viewModel.dependencies ) ) @@ -55,12 +55,28 @@ extension ConversationVC: case (.userCount, .group, true, _), (.userCount, .legacyGroup, true, _): let viewController: UIViewController = ThreadSettingsViewModel.createMemberListViewController( - threadId: self.viewModel.threadData.threadId, - transitionToConversation: { [weak self, dependencies = viewModel.dependencies] selectedMemberId in + threadId: self.viewModel.state.threadId, + transitionToConversation: { [weak self, dependencies = viewModel.dependencies] maybeThreadViewModel in + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + self?.navigationController?.present( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + animated: true, + completion: nil + ) + return + } + self?.navigationController?.pushViewController( ConversationVC( - threadId: selectedMemberId, - threadVariant: .contact, + threadViewModel: threadViewModel, + focusedInteractionInfo: nil, using: dependencies ), animated: true @@ -71,16 +87,16 @@ extension ConversationVC: navigationController?.pushViewController(viewController, animated: true) case (.disappearingMessageSetting, _, _, _): - guard let config: DisappearingMessagesConfiguration = self.viewModel.threadData.disappearingMessagesConfiguration else { + guard let config: DisappearingMessagesConfiguration = self.viewModel.state.threadViewModel.disappearingMessagesConfiguration else { return openSettings() } let viewController = SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - currentUserIsClosedGroupMember: self.viewModel.threadData.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.viewModel.threadData.currentUserIsClosedGroupAdmin, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + currentUserIsClosedGroupMember: self.viewModel.state.threadViewModel.currentUserIsClosedGroupMember, + currentUserIsClosedGroupAdmin: self.viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin, config: config, using: self.viewModel.dependencies ) @@ -93,8 +109,8 @@ extension ConversationVC: @objc func openSettings() { let viewController = SessionTableViewController(viewModel: ThreadSettingsViewModel( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, didTriggerSearch: { [weak self] in DispatchQueue.main.async { self?.showSearchUI() @@ -129,7 +145,7 @@ extension ConversationVC: // MARK: - Call @objc func startCall(_ sender: Any?) { - guard viewModel.threadData.threadIsBlocked != true else { + guard viewModel.state.threadViewModel.threadIsBlocked != true else { self.showBlockedModalIfNeeded() return } @@ -195,17 +211,15 @@ extension ConversationVC: return } - let threadId: String = self.viewModel.threadData.threadId - guard Permissions.microphone == .granted, - self.viewModel.threadData.threadVariant == .contact, + self.viewModel.state.threadVariant == .contact, viewModel.dependencies[singleton: .callManager].currentCall == nil else { return } let call: SessionCall = SessionCall( - for: threadId, - contactName: self.viewModel.threadData.displayName, + for: self.viewModel.state.threadId, + contactName: self.viewModel.state.threadViewModel.displayName, uuid: UUID().uuidString.lowercased(), mode: .offer, using: viewModel.dependencies @@ -222,19 +236,19 @@ extension ConversationVC: @MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool { guard - self.viewModel.threadData.threadVariant == .contact && - self.viewModel.threadData.threadIsBlocked == true + self.viewModel.state.threadVariant == .contact && + self.viewModel.state.threadViewModel.threadIsBlocked == true else { return false } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: String( format: "blockUnblock".localized(), - self.viewModel.threadData.displayName + self.viewModel.state.threadViewModel.displayName ), body: .attributedText( "blockUnblockName" - .put(key: "name", value: viewModel.threadData.displayName) + .put(key: "name", value: viewModel.state.threadViewModel.displayName) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ), confirmTitle: "blockUnblock".localized(), @@ -491,8 +505,8 @@ extension ConversationVC: } func handleLibraryButtonTapped() { - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] in DispatchQueue.main.async { @@ -518,8 +532,8 @@ extension ConversationVC: } let sendMediaNavController = SendMediaNavigationController.showingCameraFirst( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, using: self.viewModel.dependencies ) sendMediaNavController.sendMediaNavDelegate = self @@ -536,11 +550,11 @@ extension ConversationVC: func showAttachmentApprovalDialog(for attachments: [PendingAttachment]) { guard let navController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, attachments: attachments, approvalDelegate: self, - disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + disableLinkPreviewImageDownload: (self.viewModel.state.threadViewModel.threadCanUpload != true), didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } @@ -552,7 +566,7 @@ extension ConversationVC: // MARK: - InputViewDelegate @MainActor func handleDisabledInputTapped() { - guard viewModel.threadData.threadIsBlocked == true else { return } + guard viewModel.state.threadViewModel.threadIsBlocked == true else { return } self.showBlockedModalIfNeeded() } @@ -614,7 +628,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.threadData.threadIsMessageRequest == true else { return } + guard viewModel.state.threadViewModel.threadIsMessageRequest == true else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastAttachments".localized(), @@ -631,7 +645,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.threadData.threadIsMessageRequest == true else { return } + guard viewModel.state.threadViewModel.threadIsMessageRequest == true else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastVoiceMessages".localized(), @@ -724,7 +738,7 @@ extension ConversationVC: // If we have no content then do nothing guard !processedText.isEmpty || !attachments.isEmpty else { return } - if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { + if processedText.contains(mnemonic) && !viewModel.state.threadViewModel.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -770,10 +784,10 @@ extension ConversationVC: quoteModel: quoteModel ) await approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + displayName: self.viewModel.state.threadViewModel.displayName, + isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) @@ -782,14 +796,14 @@ extension ConversationVC: } private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async { - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant // Actually send the message do { try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { + if self?.viewModel.state.threadViewModel.threadShouldBeVisible == false { try SessionThread.updateVisibility( db, threadId: threadId, @@ -802,7 +816,11 @@ extension ConversationVC: // Insert the interaction and associated it with the optimistically inserted message so // we can remove it once the database triggers a UI update let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db) - self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id) + self?.viewModel.associate( + db, + optimisticMessageId: optimisticData.temporaryId, + to: insertedInteraction.id + ) // If there is a LinkPreview draft then check the state of any existing link previews and // insert a new one if needed @@ -810,10 +828,13 @@ extension ConversationVC: let invalidLinkPreviewAttachmentStates: [Attachment.State] = [ .failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid ] - let linkPreviewAttachmentId: String? = try? insertedInteraction.linkPreview - .select(.attachmentId) - .asRequest(of: String.self) - .fetchOne(db) + let linkPreviewAttachmentId: String? = try? Interaction + .linkPreview( + url: insertedInteraction.linkPreviewUrl, + timestampMs: insertedInteraction.timestampMs + )? + .fetchOne(db)? + .attachmentId let linkPreviewAttachmentState: Attachment.State = linkPreviewAttachmentId .map { try? Attachment @@ -860,7 +881,6 @@ extension ConversationVC: // FIXME: Remove this once we don't generate unique Profile entries for the current users blinded ids if (try? SessionId.Prefix(from: optimisticData.interaction.authorId)) != .standard { let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - let sentTimestamp: TimeInterval = (Double(optimisticData.interaction.timestampMs) / 1000) try? Profile.updateIfNeeded( db, @@ -889,7 +909,7 @@ extension ConversationVC: await handleMessageSent() } catch { - viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + await viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.temporaryId, error: error) } } @@ -900,10 +920,10 @@ extension ConversationVC: } await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( - threadId: viewModel.threadData.threadId, + threadId: viewModel.state.threadId, direction: .outgoing ) - try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.threadData.threadId] db in + try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.state.threadId] db in _ = try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) @@ -948,17 +968,20 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { - Task { [threadData = viewModel.threadData, dependencies = viewModel.dependencies] in + Task { [state = viewModel.state, dependencies = viewModel.dependencies] in await viewModel.dependencies[singleton: .typingIndicators].startIfNeeded( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, + threadId: state.threadId, + threadVariant: state.threadVariant, direction: .outgoing, timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) } } - updateMentions(for: newText) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.updateMentions(for: newText) + } + // Note: When calculating the number of characters left, we need to use the original mention // text which contains the session id rather than display name. snInputView.updateNumberOfCharactersLeft(replaceMentions(in: newText)) @@ -974,11 +997,11 @@ extension ConversationVC: ) guard let approvalVC = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadId: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, attachments: [ pendingAttachment ], approvalDelegate: self, - disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + disableLinkPreviewImageDownload: (self.viewModel.state.threadViewModel.threadCanUpload != true), didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } @@ -995,8 +1018,8 @@ extension ConversationVC: mentions.append(mentionInfo) let displayNameForMention: String = mentionInfo.profile.displayNameForMention( - for: self.viewModel.threadData.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) + for: self.viewModel.state.threadVariant, + currentUserSessionIds: self.viewModel.state.currentUserSessionIds ) let newText: String = snInputView.text.replacingCharacters( @@ -1011,20 +1034,22 @@ extension ConversationVC: mentions = mentions.filter { mentionInfo -> Bool in newText.contains( mentionInfo.profile.displayNameForMention( - for: self.viewModel.threadData.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) + for: self.viewModel.state.threadVariant, + currentUserSessionIds: self.viewModel.state.currentUserSessionIds ) ) } } - func updateMentions(for newText: String) { + func updateMentions(for newText: String) async { guard !newText.isEmpty else { - if currentMentionStartIndex != nil { - snInputView.hideMentionsUI() + await MainActor.run { + if currentMentionStartIndex != nil { + snInputView.hideMentionsUI() + } + + resetMentions() } - - resetMentions() return } @@ -1043,23 +1068,33 @@ extension ConversationVC: // stringlint:ignore_start if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine { - currentMentionStartIndex = lastCharacterIndex - snInputView.showMentionsUI( - for: self.viewModel.mentions(), - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) + let mentions = await self.viewModel.mentions() + + await MainActor.run { + currentMentionStartIndex = lastCharacterIndex + snInputView.showMentionsUI( + for: mentions, + currentUserSessionIds: self.viewModel.state.currentUserSessionIds + ) + } } else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ - currentMentionStartIndex = nil - snInputView.hideMentionsUI() + await MainActor.run { + currentMentionStartIndex = nil + snInputView.hideMentionsUI() + } } else { - if let currentMentionStartIndex = currentMentionStartIndex { + if let currentMentionStartIndex = await MainActor.run(body: { currentMentionStartIndex }) { let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ - snInputView.showMentionsUI( - for: self.viewModel.mentions(for: query), - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) + let mentions = await self.viewModel.mentions(for: query) + + await MainActor.run { + snInputView.showMentionsUI( + for: mentions, + currentUserSessionIds: self.viewModel.state.currentUserSessionIds + ) + } } } // stringlint:ignore_stop @@ -1077,7 +1112,7 @@ extension ConversationVC: for mention in mentions { let displayNameForMention: String = mention.profile.displayNameForMention( for: mention.threadVariant, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) + currentUserSessionIds: self.viewModel.state.currentUserSessionIds ) guard let range = result.range(of: "@\(displayNameForMention)") else { continue } result = result.replacingCharacters(in: range, with: "@\(mention.profile.id)") @@ -1127,7 +1162,7 @@ extension ConversationVC: func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the unblock modal if needed - guard self.viewModel.threadData.threadIsBlocked != true else { + guard self.viewModel.state.threadViewModel.threadIsBlocked != true else { self.showBlockedModalIfNeeded() return } @@ -1135,9 +1170,9 @@ extension ConversationVC: guard // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) let keyWindow: UIWindow = UIApplication.shared.keyWindow, - let sectionIndex: Int = self.viewModel.interactionData + let sectionIndex: Int = self.sections .firstIndex(where: { $0.model == .messages }), - let index = self.viewModel.interactionData[sectionIndex] + let index = self.sections[sectionIndex] .elements .firstIndex(of: cellViewModel), let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell, @@ -1146,7 +1181,9 @@ extension ConversationVC: contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.threadData, + in: self.viewModel.state.threadViewModel, + reactionsSupported: self.viewModel.state.reactionsSupported, + isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: false, delegate: self, using: viewModel.dependencies @@ -1281,6 +1318,12 @@ extension ConversationVC: disappearingMessagesConfig: messageDisappearingConfig, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: cellViewModel.threadId, + type: .updated(.disappearingMessageConfiguration(messageDisappearingConfig)) + ) } self?.dismiss(animated: true, completion: nil) } @@ -1347,7 +1390,7 @@ extension ConversationVC: case .failedUpload: break case .failedDownload: - let threadId: String = self.viewModel.threadData.threadId + let threadId: String = self.viewModel.state.threadId // Retry downloading the failed attachment viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in @@ -1398,8 +1441,8 @@ extension ConversationVC: } let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, interactionId: cellViewModel.id, selectedAttachmentId: mediaView.attachment.id, options: [ .sliderEnabled, .showAllMediaButton ], @@ -1431,7 +1474,7 @@ extension ConversationVC: case .audio: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), - let attachment: Attachment = cellViewModel.attachments?.first, + let attachment: Attachment = cellViewModel.attachments.first, let path: String = try? viewModel.dependencies[singleton: .attachmentManager] .createTemporaryFileForOpening( downloadUrl: attachment.downloadUrl, @@ -1458,7 +1501,7 @@ extension ConversationVC: case .genericAttachment: guard !handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView), - let attachment: Attachment = cellViewModel.attachments?.first, + let attachment: Attachment = cellViewModel.attachments.first, let path: String = try? viewModel.dependencies[singleton: .attachmentManager] .createTemporaryFileForOpening( downloadUrl: attachment.downloadUrl, @@ -1523,7 +1566,7 @@ extension ConversationVC: case (true, true, _, .some(let quotedInfo), _), (false, _, _, .some(let quotedInfo), _): let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in try Interaction - .filter(id: quotedInfo.quotedInteractionId) + .filter(id: quotedInfo.interactionId) .select(.timestampMs) .asRequest(of: Int64.self) .fetchOne(db) @@ -1535,7 +1578,7 @@ extension ConversationVC: self.scrollToInteractionIfNeeded( with: Interaction.TimestampInfo( - id: quotedInfo.quotedInteractionId, + id: quotedInfo.interactionId, timestampMs: timestampMs ), focusBehaviour: .highlight, @@ -1618,7 +1661,7 @@ extension ConversationVC: } func showUserProfileModal(for cellViewModel: MessageViewModel) { - guard viewModel.threadData.threadCanWrite == true else { return } + guard viewModel.state.threadViewModel.threadCanWrite == true else { return } // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) guard (try? SessionId.Prefix(from: cellViewModel.authorId)) != .blinded25 else { return } @@ -1638,8 +1681,8 @@ extension ConversationVC: let (sessionId, blindedId): (String?, String?) = { guard (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey + let openGroupServer: String = viewModel.state.threadViewModel.openGroupServer, + let openGroupPublicKey: String = viewModel.state.threadViewModel.openGroupPublicKey else { return (cellViewModel.authorId, nil) } @@ -1655,25 +1698,20 @@ extension ConversationVC: } return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) }() - let (displayName, contactDisplayName): (String?, String?) = { guard let sessionId: String = sessionId else { return (cellViewModel.authorNameSuppressedId, nil) } - - let profile: Profile? = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? - dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } - ) - - let isCurrentUser: Bool = (viewModel.threadData.currentUserSessionIds?.contains(sessionId) == true) - guard !isCurrentUser else { + guard !viewModel.state.currentUserSessionIds.contains(sessionId) else { return ("you".localized(), "you".localized()) } return ( - (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), - profile?.displayName(for: .contact, ignoringNickname: true) + ( + viewModel.state.profileCache[sessionId]?.displayName(for: .contact) ?? + cellViewModel.authorNameSuppressedId + ), + viewModel.state.profileCache[sessionId]?.displayName(for: .contact, ignoringNickname: true) ) }() @@ -1684,7 +1722,8 @@ extension ConversationVC: let isMessasgeRequestsEnabled: Bool = { guard cellViewModel.threadVariant == .community else { return true } - return cellViewModel.profile?.blocksCommunityMessageRequests != true + + return cellViewModel.profile.blocksCommunityMessageRequests != true }() self.hideInputAccessoryView() @@ -1700,11 +1739,13 @@ extension ConversationVC: isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: { [weak self] in - self?.startThread( - with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( + with: cellViewModel.authorId, + openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, + openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey + ) + } }, onProBadgeTapped: { [weak self, dependencies] in dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( @@ -1736,57 +1777,39 @@ extension ConversationVC: with sessionId: String, openGroupServer: String?, openGroupPublicKey: String? - ) { - guard viewModel.threadData.threadCanWrite == true else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: sessionId)) != .blinded25 else { return } - guard (try? SessionId.Prefix(from: sessionId)) == .blinded15 else { - viewModel.dependencies[singleton: .storage].write { [dependencies = viewModel.dependencies] db in - try SessionThread.upsert( - db, - id: sessionId, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies - ) - } - - let conversationVC: ConversationVC = ConversationVC( - threadId: sessionId, - threadVariant: .contact, - using: viewModel.dependencies - ) - - self.navigationController?.pushViewController(conversationVC, animated: true) - return - } - - // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact - // and use that, otherwise just use the blinded id - guard let openGroupServer: String = openGroupServer, let openGroupPublicKey: String = openGroupPublicKey else { - return - } + ) async { + guard viewModel.state.threadViewModel.threadCanWrite == true else { return } - let targetThreadId: String? = viewModel.dependencies[singleton: .storage].write { [dependencies = viewModel.dependencies] db in - let lookup: BlindedIdLookup = try BlindedIdLookup - .fetchOrCreate( - db, - blindedId: sessionId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: dependencies - ) + let userSessionId: SessionId = viewModel.state.userSessionId + let maybeThreadViewModel: SessionThreadViewModel? = try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + let targetId: String + + switch try? SessionId.Prefix(from: sessionId) { + case .blinded15, .blinded25: + /// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact and use that, + /// otherwise just use the blinded id + guard + let openGroupServer: String = openGroupServer, + let openGroupPublicKey: String = openGroupPublicKey + else { throw StorageError.objectNotFound } + + let lookup: BlindedIdLookup = try BlindedIdLookup.fetchOrCreate( + db, + blindedId: sessionId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + + targetId = (lookup.sessionId ?? lookup.blindedId) + + default: targetId = sessionId + } - return try SessionThread.upsert( + try SessionThread.upsert( db, - id: (lookup.sessionId ?? lookup.blindedId), + id: targetId, variant: .contact, values: SessionThread.TargetValues( creationDateTimestamp: .useExistingOrSetTo( @@ -1796,28 +1819,56 @@ extension ConversationVC: isDraft: .useExistingOrSetTo(true) ), using: dependencies - ).id + ) + + return try ConversationViewModel.fetchThreadViewModel( + db, + threadId: sessionId, + userSessionId: userSessionId, + currentUserSessionIds: [userSessionId.hexString], + threadWasKickedFromGroup: false, + threadGroupIsDestroyed: false, + using: dependencies + ) } - guard let threadId: String = targetThreadId else { return } - - let conversationVC: ConversationVC = ConversationVC( - threadId: threadId, - threadVariant: .contact, - using: viewModel.dependencies - ) - self.navigationController?.pushViewController(conversationVC, animated: true) + await MainActor.run { [dependencies = viewModel.dependencies] in + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + self.navigationController?.present( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + animated: true, + completion: nil + ) + return + } + + self.navigationController?.pushViewController( + ConversationVC( + threadViewModel: threadViewModel, + focusedInteractionInfo: nil, + using: dependencies + ), + animated: true + ) + } } func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) { guard - cellViewModel.reactionInfo?.isEmpty == false && - ( - self.viewModel.threadData.threadVariant == .legacyGroup || - self.viewModel.threadData.threadVariant == .group || - self.viewModel.threadData.threadVariant == .community - ), - let allMessages: [MessageViewModel] = self.viewModel.interactionData + !cellViewModel.reactionInfo.isEmpty && + [ + SessionThread.Variant.legacyGroup, + SessionThread.Variant.group, + SessionThread.Variant.community + ].contains(self.viewModel.state.threadVariant), + let allMessages: [MessageViewModel] = self.sections .first(where: { $0.model == .messages })? .elements else { return } @@ -1830,12 +1881,7 @@ extension ConversationVC: allMessages, selectedReaction: selectedReaction, initialLoad: true, - shouldShowClearAllButton: viewModel.dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: self.viewModel.threadData.currentUserSessionId, - for: self.viewModel.threadData.openGroupRoomToken, - on: self.viewModel.threadData.openGroupServer, - currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) - ) + shouldShowClearAllButton: viewModel.state.isUserModeratorOrAdmin ) reactionListSheet.modalPresentationStyle = .overFullScreen present(reactionListSheet, animated: true, completion: nil) @@ -1846,9 +1892,9 @@ extension ConversationVC: func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) { guard - let messageSectionIndex: Int = self.viewModel.interactionData + let messageSectionIndex: Int = self.sections .firstIndex(where: { $0.model == .messages }), - let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + let targetMessageIndex = self.sections[messageSectionIndex] .elements .firstIndex(where: { $0.id == cellViewModel.id }) else { return } @@ -1888,13 +1934,17 @@ extension ConversationVC: } func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) { - react(cellViewModel, with: emoji.rawValue, remove: false) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.react(cellViewModel, with: emoji.rawValue, remove: false) + } } func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) { - guard viewModel.threadData.threadVariant != .legacyGroup else { return } + guard viewModel.state.threadVariant != .legacyGroup else { return } - react(cellViewModel, with: emoji.rawValue, remove: true) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.react(cellViewModel, with: emoji.rawValue, remove: true) + } } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { @@ -1915,7 +1965,9 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] modal in // Call clear reaction event - self?.clearAllReactions(cellViewModel, for: emoji) + Task.detached(priority: .userInitiated) { + await self?.clearAllReactions(cellViewModel, for: emoji) + } modal.dismiss(animated: true) } ) @@ -1924,27 +1976,26 @@ extension ConversationVC: present(modal, animated: true, completion: nil) } - func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { + func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) async { guard cellViewModel.threadVariant == .community, - let roomToken: String = viewModel.threadData.openGroupRoomToken, - let server: String = viewModel.threadData.openGroupServer, - let publicKey: String = viewModel.threadData.openGroupPublicKey, - let capabilities: Set = viewModel.threadData.openGroupCapabilities, + let roomToken: String = viewModel.state.threadViewModel.openGroupRoomToken, + let server: String = viewModel.state.threadViewModel.openGroupServer, + let publicKey: String = viewModel.state.threadViewModel.openGroupPublicKey, + let capabilities: Set = viewModel.state.threadViewModel.openGroupCapabilities, let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId else { return } - let pendingChange: OpenGroupManager.PendingChange = viewModel.dependencies[singleton: .openGroupManager] - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: roomToken, - on: server, - type: .removeAll - ) - - Result { - try Network.SOGS.preparedReactionDeleteAll( + do { + let pendingChange: CommunityManager.PendingChange = await viewModel.dependencies[singleton: .communityManager] + .addPendingReaction( + emoji: emoji, + id: openGroupServerMessageId, + in: roomToken, + on: server, + type: .removeAll + ) + let request = try Network.SOGS.preparedReactionDeleteAll( emoji: emoji, id: openGroupServerMessageId, roomToken: roomToken, @@ -1958,56 +2009,79 @@ extension ConversationVC: ), using: viewModel.dependencies ) - } - .publisher - .flatMap { [dependencies = viewModel.dependencies] in $0.send(using: dependencies) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .sinkUntilComplete( - receiveCompletion: { [dependencies = viewModel.dependencies] _ in - dependencies[singleton: .storage].writeAsync { db in - _ = try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SOGS.ReactionRemoveAllResponse = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + await viewModel.dependencies[singleton: .communityManager].updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + + try await viewModel.dependencies[singleton: .storage].writeAsync { db in + let rowIds: [Int64] = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .asRequest(of: Int64.self) + .fetchAll(db) + + _ = try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + + rowIds.forEach { + db.addReactionEvent( + id: $0, + messageId: cellViewModel.id, + change: .removed(emoji) + ) } - }, - receiveValue: { [dependencies = viewModel.dependencies] _, response in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) } - ) + } + catch { + // FIXME: Should probably handle this error + } } - func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { + func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) async { guard - self.viewModel.threadData.threadIsMessageRequest != true && ( + self.viewModel.state.reactionsSupported && + self.viewModel.state.threadViewModel.threadIsMessageRequest != true && ( cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing ) else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant + let openGroupRoom: String? = self.viewModel.state.threadViewModel.openGroupRoomToken + let openGroupServer: String? = self.viewModel.state.threadViewModel.openGroupServer + let openGroupPublicKey: String? = self.viewModel.state.threadViewModel.openGroupPublicKey let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let recentReactionTimestamps: [Int64] = viewModel.dependencies[cache: .general].recentReactionTimestamps + let currentUserSessionIds: Set = viewModel.state.currentUserSessionIds guard recentReactionTimestamps.count < 20 || (sentTimestampMs - (recentReactionTimestamps.first ?? sentTimestampMs)) > (60 * 1000) else { - let toastController: ToastController = ToastController( - text: "emojiReactsCoolDown".localized(), - background: .backgroundSecondary - ) - toastController.presentToastView( - fromBottomOfView: self.view, - inset: (snInputView.bounds.height + Values.largeSpacing), - duration: .milliseconds(2500) - ) + await MainActor.run { + let toastController: ToastController = ToastController( + text: "emojiReactsCoolDown".localized(), + background: .backgroundSecondary + ) + toastController.presentToastView( + fromBottomOfView: self.view, + inset: (snInputView.bounds.height + Values.largeSpacing), + duration: .milliseconds(2500) + ) + } return } @@ -2017,164 +2091,157 @@ extension ConversationVC: .appending(sentTimestampMs) } - typealias OpenGroupInfo = ( - pendingReaction: Reaction?, - pendingChange: OpenGroupManager.PendingChange, - preparedRequest: Network.PreparedRequest - ) - /// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup /// cache from blocking either the main thread or the database write thread - Deferred { [dependencies = viewModel.dependencies] in - Future { resolver in + var pendingReaction: Reaction? + var pendingChange: CommunityManager.PendingChange? + + do { + // Create the pending change if we have open group info + let threadShouldBeVisible: Bool? = self.viewModel.state.threadViewModel.threadShouldBeVisible + + if threadVariant == .community { guard - threadVariant == .community, let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey - else { return resolver(Result.success(nil)) } - - // Create the pending change if we have open group info - return resolver(Result.success( - dependencies[singleton: .openGroupManager].addPendingReaction( - emoji: emoji, - id: serverMessageId, - in: openGroupServer, - on: openGroupPublicKey, - type: (remove ? .remove : .add) - ) - )) - } - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: viewModel.dependencies) - .flatMapStorageWritePublisher(using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] db, pendingChange -> (OpenGroupManager.PendingChange?, Reaction?, Message.Destination, AuthenticationMethod) in - // Update the thread to be visible (if it isn't already) - if self?.viewModel.threadData.threadShouldBeVisible == false { - try SessionThread.updateVisibility( - db, - threadId: cellViewModel.threadId, - isVisible: true, - using: dependencies + let openGroupServer: String = openGroupServer, + let openGroupPublicKey: String = openGroupPublicKey + else { throw MessageError.invalidMessage("Missing community info for adding reaction") } + + pendingChange = await viewModel.dependencies[singleton: .communityManager].addPendingReaction( + emoji: emoji, + id: serverMessageId, + in: openGroupServer, + on: openGroupPublicKey, + type: (remove ? .remove : .add) ) } - let pendingReaction: Reaction? = { - guard !remove else { - return try? Reaction + let (destination, authMethod): (Message.Destination, AuthenticationMethod) = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in + // Update the thread to be visible (if it isn't already) + if threadShouldBeVisible == false { + try SessionThread.updateVisibility( + db, + threadId: cellViewModel.threadId, + isVisible: true, + using: dependencies + ) + } + + // Get the pending reaction + if remove { + pendingReaction = try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) - // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable - .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) .fetchOne(db) } + else { + let sortId: Int64 = Reaction.getSortId( + db, + interactionId: cellViewModel.id, + emoji: emoji + ) + + pendingReaction = Reaction( + interactionId: cellViewModel.id, + serverHash: nil, + timestampMs: sentTimestampMs, + authorId: state.userSessionId.hexString, + emoji: emoji, + count: 1, + sortId: sortId + ) + } - let sortId: Int64 = Reaction.getSortId( - db, - interactionId: cellViewModel.id, - emoji: emoji - ) + // Update the database + if remove { + let maybeRowId: Int64? = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .asRequest(of: Int64.self) + .fetchOne(db) + + try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(currentUserSessionIds.contains(Reaction.Columns.authorId)) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) + + if let rowId: Int64 = maybeRowId { + db.addReactionEvent( + id: rowId, + messageId: cellViewModel.id, + change: .removed(emoji) + ) + } + } + else { + try pendingReaction?.insert(db) + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: cellViewModel.id, + change: .added(emoji) + ) + + // Add it to the recent list + Emoji.addRecent(db, emoji: emoji) + } - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestampMs, - authorId: cellViewModel.currentUserSessionId, - emoji: emoji, - count: 1, - sortId: sortId + return ( + try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), + try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) ) - }() - - // Update the database - if remove { - try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - // TODO: [Database Relocation] Stop `currentUserSessionIds` from being nullable - .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) - } - else { - try pendingReaction?.insert(db) - - // Add it to the recent list - Emoji.addRecent(db, emoji: emoji) } - switch threadVariant { - case .community: - guard - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - dependencies[singleton: .openGroupManager].doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) - else { throw MessageError.invalidMessage("Community does not support reactions") } - - default: break - } - - return ( - pendingChange, - pendingReaction, - try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) - ) - } - .tryFlatMap { [dependencies = viewModel.dependencies] pendingChange, pendingReaction, destination, authMethod in switch threadVariant { case .community: guard let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = cellViewModel.threadOpenGroupServer, - let openGroupRoom: String = openGroupRoom, - let pendingChange: OpenGroupManager.PendingChange = pendingChange - else { throw MessageError.missingRequiredField } + let openGroupRoom: String = openGroupRoom + else { throw MessageError.invalidMessage("Missing community info for adding reaction") } - let preparedRequest: Network.PreparedRequest = try { - guard !remove else { - return try Network.SOGS - .preparedReactionDelete( - emoji: emoji, - id: serverMessageId, - roomToken: openGroupRoom, - authMethod: authMethod, - using: dependencies - ) - .map { _, response in response.seqNo } - } - - return try Network.SOGS + let request: Network.PreparedRequest + + if remove { + request = try Network.SOGS + .preparedReactionDelete( + emoji: emoji, + id: serverMessageId, + roomToken: openGroupRoom, + authMethod: authMethod, + using: viewModel.dependencies + ) + .map { _, response in response.seqNo } + } + else { + request = try Network.SOGS .preparedReactionAdd( emoji: emoji, id: serverMessageId, roomToken: openGroupRoom, authMethod: authMethod, - using: dependencies + using: viewModel.dependencies ) .map { _, response in response.seqNo } - }() + } - return preparedRequest - .handleEvents( - receiveOutput: { _, seqNo in - dependencies[singleton: .openGroupManager].updatePendingChange( - pendingChange, - seqNo: seqNo - ) - }, - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - dependencies[singleton: .openGroupManager].removePendingChange(pendingChange) - - self?.handleReactionSentFailure(pendingReaction, remove: remove) - } - } + // FIXME: Make this async/await when the refactored networking is merged + let seqNo: Int64? = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + if let pendingChange: CommunityManager.PendingChange = pendingChange { + await viewModel.dependencies[singleton: .communityManager].updatePendingChange( + pendingChange, + seqNo: seqNo ) - .map { _, _ in () } - .send(using: dependencies) + } default: - return try MessageSender.preparedSend( + let request: Network.PreparedRequest = try MessageSender.preparedSend( message: VisibleMessage( sentTimestampMs: UInt64(sentTimestampMs), text: nil, @@ -2182,7 +2249,7 @@ extension ConversationVC: timestamp: UInt64(cellViewModel.timestampMs), publicKey: { guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserSessionId + return viewModel.state.userSessionId.hexString } return cellViewModel.authorId @@ -2196,19 +2263,29 @@ extension ConversationVC: interactionId: cellViewModel.id, attachments: nil, authMethod: authMethod, - onEvent: MessageSender.standardEventHandling(using: dependencies), - using: dependencies + onEvent: MessageSender.standardEventHandling(using: viewModel.dependencies), + using: viewModel.dependencies ) - .map { _, _ in () } - .send(using: dependencies) + // FIXME: Make this async/await when the refactored networking is merged + _ = try await request + .send(using: viewModel.dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + } + } + catch { + if let pendingChange: CommunityManager.PendingChange = pendingChange { + await viewModel.dependencies[singleton: .communityManager].removePendingChange(pendingChange) } + + await handleReactionSentFailure(pendingReaction, remove: remove) } - .sinkUntilComplete() } - func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) { + func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) async { guard let pendingReaction = pendingReaction else { return } - viewModel.dependencies[singleton: .storage].writeAsync { db in + + try? await viewModel.dependencies[singleton: .storage].writeAsync { db in // Reverse the database if remove { try pendingReaction.insert(db) @@ -2277,7 +2354,7 @@ extension ConversationVC: dependencies[singleton: .storage] .writePublisher { db in - dependencies[singleton: .openGroupManager].add( + dependencies[singleton: .communityManager].add( db, roomToken: room, server: server, @@ -2286,7 +2363,7 @@ extension ConversationVC: ) } .flatMap { successfullyAddedGroup in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: room, @@ -2305,7 +2382,7 @@ extension ConversationVC: // the next launch so remove it (the user will be left on the previous // screen so can re-trigger the join) dependencies[singleton: .storage].writeAsync { db in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: OpenGroup.idFor(roomToken: room, server: server), skipLibSessionUpdate: false @@ -2338,34 +2415,27 @@ extension ConversationVC: func info(_ cellViewModel: MessageViewModel) { let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.threadData, + in: self.viewModel.state.threadViewModel, + reactionsSupported: self.viewModel.state.reactionsSupported, + isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: true, delegate: self, using: viewModel.dependencies ) ?? [] // FIXME: This is an interim solution until the `ConversationViewModel` queries are refactored to use the new observation system - var finalCellViewModel: MessageViewModel = cellViewModel - - if - viewModel.threadData.currentUserSessionIds?.contains(cellViewModel.authorId) == true && - cellViewModel.authorId != viewModel.threadData.currentUserSessionId - { - finalCellViewModel = finalCellViewModel.with( - profile: .set(to: viewModel.dependencies.mutate(cache: .libSession) { $0.profile }) - ) - } - let messageInfoViewController = MessageInfoViewController( actions: actions, - messageViewModel: finalCellViewModel, - threadCanWrite: (viewModel.threadData.threadCanWrite == true), + messageViewModel: cellViewModel, + threadCanWrite: (viewModel.state.threadViewModel.threadCanWrite == true), onStartThread: { [weak self] in - self?.startThread( - with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( + with: cellViewModel.authorId, + openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, + openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey + ) + } }, using: viewModel.dependencies ) @@ -2375,10 +2445,11 @@ extension ConversationVC: } @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) { - guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { + guard cellViewModel.optimisticMessageId == nil else { guard - let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, - let optimisticMessageData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticMessageData(for: optimisticMessageId) + let optimisticMessageId: Int64 = cellViewModel.optimisticMessageId, + let optimisticMessageData: ConversationViewModel.OptimisticMessageData = self.viewModel.state + .optimisticallyInsertedMessages[optimisticMessageId] else { // Show an error for the retry let modal: ConfirmationModal = ConfirmationModal( @@ -2409,8 +2480,8 @@ extension ConversationVC: viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in guard - let threadId: String = self?.viewModel.threadData.threadId, - let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, + let threadId: String = self?.viewModel.state.threadId, + let threadVariant: SessionThread.Variant = self?.viewModel.state.threadVariant, let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id) else { return } @@ -2433,14 +2504,17 @@ extension ConversationVC: func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( - threadId: self.viewModel.threadData.threadId, + threadId: self.viewModel.state.threadId, + quotedInteractionId: cellViewModel.id, authorId: cellViewModel.authorId, + authorName: cellViewModel.authorNameSuppressedId, variant: cellViewModel.variant, body: cellViewModel.body, timestampMs: cellViewModel.timestampMs, attachments: cellViewModel.attachments, linkPreviewAttachment: cellViewModel.linkPreviewAttachment, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []) + proFeatures: cellViewModel.proFeatures, + currentUserSessionIds: cellViewModel.currentUserSessionIds ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } @@ -2486,8 +2560,8 @@ extension ConversationVC: case .audio, .voiceMessage, .genericAttachment, .mediaMessage: guard - cellViewModel.attachments?.count == 1, - let attachment: Attachment = cellViewModel.attachments?.first, + cellViewModel.attachments.count == 1, + let attachment: Attachment = cellViewModel.attachments.first, attachment.isValid, ( attachment.state == .downloaded || @@ -2632,7 +2706,7 @@ extension ConversationVC: } func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { - let validAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) + let validAttachments: [(Attachment, String)] = cellViewModel.attachments .filter { attachment in attachment.isValid && ( cellViewModel.cellType != .mediaMessage || @@ -2684,7 +2758,7 @@ extension ConversationVC: ) // Send a 'media saved' notification if needed - guard self?.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + guard self?.viewModel.state.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } @@ -2744,7 +2818,7 @@ extension ConversationVC: } // Send a 'media saved' notification if needed - guard self?.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + guard self?.viewModel.state.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } @@ -2770,7 +2844,7 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadData = viewModel.state.threadViewModel, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, @@ -2848,15 +2922,14 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.threadData, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadData = viewModel.state.threadViewModel, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, let roomToken: String = threadData.openGroupRoomToken, let server: String = threadData.openGroupServer, let publicKey: String = threadData.openGroupPublicKey, - let capabilities: Set = threadData.openGroupCapabilities, - let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId + let capabilities: Set = threadData.openGroupCapabilities else { throw CryptoError.invalidAuthentication } return ( @@ -2941,8 +3014,7 @@ extension ConversationVC: let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) // Set up audio session - let isConfigured = (SessionEnvironment.shared?.audioSession.startAudioActivity(recordVoiceMessageActivity) == true) - guard isConfigured else { + guard viewModel.dependencies[singleton: .audioSession].startAudioActivity(recordVoiceMessageActivity) else { return cancelVoiceMessageRecording() } @@ -3056,17 +3128,17 @@ extension ConversationVC: func stopVoiceMessageRecording() { audioRecorder?.stop() - SessionEnvironment.shared?.audioSession.endAudioActivity(recordVoiceMessageActivity) + viewModel.dependencies[singleton: .audioSession].endAudioActivity(recordVoiceMessageActivity) } // MARK: - Data Extraction Notifications func sendDataExtraction(kind: DataExtractionNotification.Kind) { // Only send screenshot notifications to one-to-one conversations - guard self.viewModel.threadData.threadVariant == .contact else { return } + guard self.viewModel.state.threadVariant == .contact else { return } - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let threadId: String = self.viewModel.state.threadId + let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in try MessageSender.send( @@ -3126,6 +3198,22 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { // MARK: - Message Request Actions extension ConversationVC { + @MainActor internal func removeMessageRequestsFromBackStackIfNeeded() { + /// Remove the `SessionTableViewController` from the nav hierarchy if present + if + let viewControllers: [UIViewController] = self.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers + .firstIndex(where: { viewCon -> Bool in + (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self + }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self.navigationController?.viewControllers = newViewControllers + } + } + fileprivate func approveMessageRequestIfNeeded( for threadId: String, threadVariant: SessionThread.Variant, @@ -3133,22 +3221,6 @@ extension ConversationVC { isDraft: Bool, timestampMs: Int64 ) async { - let updateNavigationBackStack: @MainActor () -> Void = { [weak self] in - /// Remove the `SessionTableViewController` from the nav hierarchy if present - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers - .firstIndex(where: { viewCon -> Bool in - (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self - }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } - } - switch threadVariant { case .contact: /// If the contact doesn't exist then we should create it so we can store the `isApproved` state (it'll be updated @@ -3210,7 +3282,7 @@ extension ConversationVC { // Update the UI await MainActor.run { - updateNavigationBackStack() + removeMessageRequestsFromBackStackIfNeeded() } return @@ -3272,7 +3344,7 @@ extension ConversationVC { // Update the UI await MainActor.run { - updateNavigationBackStack() + removeMessageRequestsFromBackStackIfNeeded() } return @@ -3285,10 +3357,10 @@ extension ConversationVC { guard let self = self else { return } await approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + displayName: self.viewModel.state.threadViewModel.displayName, + isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) } @@ -3300,8 +3372,8 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.threadData, - viewController: self, + threadViewModel: self.viewModel.state.threadViewModel, + viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies ) @@ -3311,8 +3383,6 @@ extension ConversationVC { action.handler(action, self.view, { [weak self] didConfirm in guard didConfirm else { return } - self?.stopObservingChanges() - DispatchQueue.main.async { self?.navigationController?.popViewController(animated: true) } @@ -3325,7 +3395,7 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.threadData, + threadViewModel: self.viewModel.state.threadViewModel, viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies @@ -3336,8 +3406,6 @@ extension ConversationVC { action.handler(action, self.view, { [weak self] didConfirm in guard didConfirm else { return } - self?.stopObservingChanges() - DispatchQueue.main.async { self?.navigationController?.popViewController(animated: true) } @@ -3349,8 +3417,8 @@ extension ConversationVC { extension ConversationVC { @objc public func recreateLegacyGroupTapped() { - let threadId: String = self.viewModel.threadData.threadId - let closedGroupName: String? = self.viewModel.threadData.closedGroupName + let threadId: String = self.viewModel.state.threadId + let closedGroupName: String? = self.viewModel.state.threadViewModel.closedGroupName let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "recreateGroup".localized(), diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 3b269b50b3..b3ec7278f9 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -2,6 +2,7 @@ import UIKit import AVKit +import Combine import GRDB import DifferenceKit import Lucide @@ -14,11 +15,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa private static let loadingHeaderHeight: CGFloat = 40 internal let viewModel: ConversationViewModel - private var dataChangeObservable: DatabaseCancellable? { - didSet { oldValue?.cancel() } // Cancel the old observable if there was one - } - private var hasLoadedInitialThreadData: Bool = false - private var hasLoadedInitialInteractionData: Bool = false + private var disposables: Set = Set() + + /// Currently loaded version of the data for the `tableView`, will always match the value in the `viewModel` unless it's part way + /// through updating it's state + internal var sections: [ConversationViewModel.SectionModel] = [] + private var initialLoadComplete: Bool = false + private var currentTargetOffset: CGPoint? private var isAutoLoadingNextPage: Bool = false private var isLoadingMore: Bool = false @@ -50,8 +53,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa var documentHandler: DocumentPickerHandler? // Mentions - var currentMentionStartIndex: String.Index? - var mentions: [MentionInfo] = [] + @MainActor var currentMentionStartIndex: String.Index? + @MainActor var mentions: [MentionInfo] = [] // Scrolling & paging var isUserScrolling = false @@ -81,7 +84,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } override var inputAccessoryView: UIView? { - return (viewModel.threadData.threadCanWrite == true && isShowingSearchUI ? + return (viewModel.state.threadViewModel.threadCanWrite == true && isShowingSearchUI ? searchController.resultsBar : snInputView ) @@ -108,12 +111,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var recordVoiceMessageActivity = AudioActivity( audioDescription: "Voice message", // stringlint:ignore - behavior: .playAndRecord + behavior: .playAndRecord, + using: viewModel.dependencies ) lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( - threadId: self.viewModel.threadData.threadId + threadId: self.viewModel.state.threadId ) result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self @@ -150,7 +154,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa result.contentInset = UIEdgeInsets( top: 0, leading: 0, - bottom: (viewModel.threadData.threadCanWrite == true ? + bottom: (viewModel.state.threadViewModel.threadCanWrite == true ? Values.mediumSpacing : (Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0)) ), @@ -173,7 +177,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa }() lazy var snInputView: InputView = InputView( - threadVariant: self.viewModel.initialThreadVariant, + threadVariant: self.viewModel.state.threadVariant, delegate: self, using: self.viewModel.dependencies ) @@ -220,7 +224,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa info: InfoBanner.Info( font: .systemFont(ofSize: Values.verySmallFontSize), message: "disappearingMessagesLegacy" - .put(key: "name", value: self.viewModel.threadData.displayName) + .put(key: "name", value: self.viewModel.state.threadViewModel.displayName) .localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)), icon: .close, tintColor: .messageBubble_outgoingText, @@ -238,8 +242,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var legacyGroupsBanner: InfoBanner = { let result: InfoBanner = InfoBanner( info: InfoBanner.Info( - font: viewModel.legacyGroupsBannerFont, - message: viewModel.legacyGroupsBannerMessage, + font: ConversationViewModel.legacyGroupsBannerFont, + message: viewModel.state.legacyGroupsBannerMessage, icon: .none, tintColor: .messageBubble_outgoingText, backgroundColor: .primary, @@ -248,7 +252,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa onTap: { [weak self] in self?.openUrl(Features.legacyGroupDepricationUrl) } ) ) - result.isHidden = (viewModel.threadData.threadVariant != .legacyGroup) + result.isHidden = (viewModel.state.threadVariant != .legacyGroup) return result }() @@ -267,8 +271,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ) ) result.isHidden = ( - viewModel.threadData.threadVariant != .group || - viewModel.threadData.closedGroupExpired != true + viewModel.state.threadVariant != .group || + viewModel.state.threadViewModel.closedGroupExpired != true ) return result @@ -297,7 +301,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa result.accessibilityIdentifier = "Control message" result.translatesAutoresizingMaskIntoConstraints = false result.font = .systemFont(ofSize: Values.verySmallFontSize) - result.themeAttributedText = viewModel.emptyStateText(for: viewModel.threadData).formatted(in: result) + result.themeAttributedText = viewModel.state.emptyStateText.formatted(in: result) result.themeTextColor = .textSecondary result.textAlignment = .center result.lineBreakMode = .byWordWrapping @@ -337,11 +341,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa }() lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView( - threadVariant: self.viewModel.threadData.threadVariant, - canWrite: (self.viewModel.threadData.threadCanWrite == true), - threadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), - threadRequiresApproval: (self.viewModel.threadData.threadRequiresApproval == true), - closedGroupAdminProfile: self.viewModel.threadData.closedGroupAdminProfile, + threadVariant: self.viewModel.state.threadVariant, + canWrite: (self.viewModel.state.threadViewModel.threadCanWrite == true), + threadIsMessageRequest: (self.viewModel.state.threadViewModel.threadIsMessageRequest == true), + threadRequiresApproval: (self.viewModel.state.threadViewModel.threadRequiresApproval == true), + closedGroupAdminProfile: self.viewModel.state.threadViewModel.closedGroupAdminProfile, onBlock: { [weak self] in self?.blockMessageRequest() }, onAccept: { [weak self] in self?.acceptMessageRequest() }, onDecline: { [weak self] in self?.declineMessageRequest() } @@ -350,8 +354,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa private lazy var legacyGroupsRecreateGroupView: UIView = { let result: UIView = UIView() result.isHidden = ( - viewModel.threadData.threadVariant != .legacyGroup || - viewModel.threadData.currentUserIsClosedGroupAdmin != true + viewModel.state.threadVariant != .legacyGroup || + viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin != true ) return result @@ -413,23 +417,16 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - Initialization init( - threadId: String, - threadVariant: SessionThread.Variant, + threadViewModel: SessionThreadViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, using dependencies: Dependencies ) { self.viewModel = ConversationViewModel( - threadId: threadId, - threadVariant: threadVariant, + threadViewModel: threadViewModel, focusedInteractionInfo: focusedInteractionInfo, using: dependencies ) - /// Dispatch adding the database observation to a background thread - DispatchQueue.global(qos: .userInitiated).async { [weak viewModel] in - dependencies[singleton: .storage].addObserver(viewModel?.pagedDataObserver) - } - super.init(nibName: nil, bundle: nil) } @@ -454,16 +451,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // there isn't much we can do about that unfortunately) updateNavBarButtons( threadData: nil, - initialVariant: self.viewModel.initialThreadVariant, - initialIsNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (self.viewModel.threadData.threadIsBlocked == true) - ) - titleView.initialSetup( - with: self.viewModel.initialThreadVariant, - isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), - isSessionPro: self.viewModel.threadData.isSessionPro(using: self.viewModel.dependencies) + initialVariant: self.viewModel.state.threadVariant, + initialIsNoteToSelf: self.viewModel.state.threadViewModel.threadIsNoteToSelf, + initialIsBlocked: (self.viewModel.state.threadViewModel.threadIsBlocked == true) ) + titleView.update(with: self.viewModel.state.titleViewModel) // Constraints view.addSubview(tableView) @@ -524,11 +516,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa name: UIApplication.didBecomeActiveNotification, object: nil ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidResignActive(_:)), - name: UIApplication.didEnterBackgroundNotification, object: nil - ) // Observe keyboard notifications let keyboardNotifications: [Notification.Name] = [ @@ -553,18 +540,19 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.viewModel.navigatableState.setupBindings(viewController: self, disposables: &self.viewModel.disposables) + // Bind the UI to the view model + bindViewModel() + // The first time the view loads we should mark the thread as read (in case it was manually // marked as unread) - doing this here means if we add a "mark as unread" action within the // conversation settings then we don't need to worry about the conversation getting marked as // when when the user returns back through this view controller - self.viewModel.markAsRead(target: .thread, timestampMs: nil) + Task { await self.viewModel.markThreadAsRead() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - startObservingChanges() - /// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear` /// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case viewIsDisappearing = false @@ -584,7 +572,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa )?.becomeFirstResponder() } } - else if !self.isFirstResponder && hasLoadedInitialThreadData && lastPresentedViewController == nil { + else if !self.isFirstResponder && initialLoadComplete && lastPresentedViewController == nil { // After we have loaded the initial data if the user starts and cancels the interactive pop // gesture the input view will disappear (but if we are returning from a presented view controller // the keyboard will automatically reappear and calling this will break the first responder state @@ -620,7 +608,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // to appear to remain focussed) guard !isReplacingThread else { return } - stopObservingChanges() viewModel.updateDraft(to: replaceMentions(in: snInputView.text)) inputAccessoryView?.resignFirstResponder() } @@ -634,15 +621,15 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa /// If the user just created this thread but didn't send a message or the conversation is marked as hidden then we want to delete the /// "shadow" thread since it's not actually in use (this is to prevent it from taking up database space or unintentionally getting synced /// via `libSession` in the future) - let threadId: String = viewModel.threadData.threadId + let threadId: String = viewModel.state.threadId if ( self.navigationController == nil || self.navigationController?.viewControllers.contains(self) == false ) && - viewModel.threadData.threadIsNoteToSelf == false && - viewModel.threadData.threadIsDraft == true + viewModel.state.threadViewModel.threadIsNoteToSelf == false && + viewModel.state.threadViewModel.threadIsDraft == true { viewModel.dependencies[singleton: .storage].writeAsync { db in _ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave` @@ -650,22 +637,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa .deleteAll(db) } } - - /// Should only be `true` when the view controller is being removed from the stack - if isMovingFromParent { - DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies = viewModel.dependencies] in - dependencies[singleton: .storage].removeObserver(self?.viewModel.pagedDataObserver) - } - } } @objc func applicationDidBecomeActive(_ notification: Notification) { - /// **Note:** When returning from the background we could have received notifications but the `PagedDatabaseObserver` - /// won't have them so we need to force a re-fetch of the current data to ensure everything is up to date - DispatchQueue.global(qos: .background).async { [weak self] in - self?.viewModel.pagedDataObserver?.resume() - } - recoverInputView() if !isShowingSearchUI && self.presentedViewController == nil { @@ -678,254 +652,59 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } - @objc func applicationDidResignActive(_ notification: Notification) { - /// When going into the background we should stop listening to database changes (we will resume/reload after returning from - /// the background) - viewModel.pagedDataObserver?.suspend() - } - // MARK: - Updating - private func startObservingChanges() { - guard dataChangeObservable == nil else { return } - - dataChangeObservable = viewModel.dependencies[singleton: .storage].start( - viewModel.observableThreadData, - onError: { _ in }, - onChange: { [weak self, dependencies = viewModel.dependencies] maybeThreadData in - guard let threadData: SessionThreadViewModel = maybeThreadData else { - // If the thread data is null and the id was blinded then we just unblinded the thread - // and need to swap over to the new one - guard - let sessionId: String = self?.viewModel.threadData.threadId, - ( - (try? SessionId.Prefix(from: sessionId)) == .blinded15 || - (try? SessionId.Prefix(from: sessionId)) == .blinded25 - ), - let blindedLookup: BlindedIdLookup = dependencies[singleton: .storage].read({ db in - try BlindedIdLookup - .filter(id: sessionId) - .fetchOne(db) - }), - let unblindedId: String = blindedLookup.sessionId - else { - // If we don't have an unblinded id then something has gone very wrong so pop to the - // nearest conversation list - let maybeTargetViewController: UIViewController? = self?.navigationController? - .viewControllers - .last(where: { ($0 as? LibSessionRespondingViewController)?.isConversationList == true }) - - if let targetViewController: UIViewController = maybeTargetViewController { - self?.navigationController?.popToViewController(targetViewController, animated: true) - } - else { - self?.navigationController?.popToRootViewController(animated: true) - } - return - } - - // Stop observing changes - self?.stopObservingChanges() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - dependencies[singleton: .storage].removeObserver(self?.viewModel.pagedDataObserver) - } - - // Swap the observing to the updated thread - let newestVisibleMessageId: Int64? = self?.fullyVisibleCellViewModels()?.last?.id - self?.viewModel.swapToThread(updatedThreadId: unblindedId, focussedMessageId: newestVisibleMessageId) - - /// Start observing changes again (on a background thread) - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - dependencies[singleton: .storage].addObserver(self?.viewModel.pagedDataObserver) - } - self?.startObservingChanges() - return + private func bindViewModel() { + viewModel.$state + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] state in + /// Don't animate the changes if it's the first load + if self?.initialLoadComplete == false { + return UIView.performWithoutAnimation { self?.render(state: state) } } - // The default scheduler emits changes on the main thread - self?.handleThreadUpdates(threadData) - - // Note: We want to load the interaction data into the UI after the initial thread data - // has loaded to prevent an issue where the conversation loads with the wrong offset - if self?.viewModel.onInteractionChange == nil { - self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData, changeset in - self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset) - } - } + self?.render(state: state) } - ) + .store(in: &disposables) } - func stopObservingChanges() { - self.dataChangeObservable?.cancel() - self.dataChangeObservable = nil - self.viewModel.onInteractionChange = nil - } - - private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { - // Ensure the first load or a load when returning from a child screen runs without animations (if - // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) - guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { - // Need to correctly determine if it's the initial load otherwise we would be needlesly updating - // extra UI elements - let isInitialLoad: Bool = ( - !hasLoadedInitialThreadData && - hasReloadedThreadDataAfterDisappearance - ) - hasLoadedInitialThreadData = true - hasReloadedThreadDataAfterDisappearance = true - - UIView.performWithoutAnimation { - handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad) - } - return + @MainActor private func render(state: ConversationViewModel.State) { + /// If we just unblinded the contact then we should remove the message requests screen from the back stack (if it's there) + if state.wasPreviouslyBlindedContact && !state.isBlindedContact { + removeMessageRequestsFromBackStackIfNeeded() } // Update general conversation UI + titleView.update(with: state.titleViewModel) + updateNavBarButtons( + threadData: state.threadViewModel, + initialVariant: state.threadVariant, + initialIsNoteToSelf: state.threadViewModel.threadIsNoteToSelf, + initialIsBlocked: (state.threadViewModel.threadIsBlocked == true) + ) - if - initialLoad || - viewModel.threadData.displayName != updatedThreadData.displayName || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || - viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || - viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || - viewModel.threadData.userCount != updatedThreadData.userCount || - viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration - { - titleView.update( - with: updatedThreadData.displayName, - isNoteToSelf: updatedThreadData.threadIsNoteToSelf, - isMessageRequest: (updatedThreadData.threadIsMessageRequest == true), - isSessionPro: updatedThreadData.isSessionPro(using: viewModel.dependencies), - threadVariant: updatedThreadData.threadVariant, - mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, - onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), - userCount: updatedThreadData.userCount, - disappearingMessagesConfig: updatedThreadData.disappearingMessagesConfiguration - ) - - // Update the empty state - emptyStateLabel.themeAttributedText = viewModel - .emptyStateText(for: updatedThreadData) - .formatted(in: emptyStateLabel) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || - viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.profile != updatedThreadData.profile || - viewModel.threadData.additionalProfile != updatedThreadData.additionalProfile || - viewModel.threadData.threadDisplayPictureUrl != updatedThreadData.threadDisplayPictureUrl - { - updateNavBarButtons( - threadData: updatedThreadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) - ) - } - - if - initialLoad || - viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || - viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || - viewModel.threadData.closedGroupAdminProfile != updatedThreadData.closedGroupAdminProfile - { - if updatedThreadData.threadCanWrite == true { - self.showInputAccessoryView() - } else if updatedThreadData.threadCanWrite == false && updatedThreadData.threadVariant != .community { - self.hideInputAccessoryView() - } - - let messageRequestsViewWasVisible: Bool = (self.messageRequestFooterView.isHidden == false) - - UIView.animate(withDuration: 0.3) { [weak self] in - self?.messageRequestFooterView.update( - threadVariant: updatedThreadData.threadVariant, - canWrite: (updatedThreadData.threadCanWrite == true), - threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true), - threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true), - closedGroupAdminProfile: updatedThreadData.closedGroupAdminProfile - ) - self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( - self?.messageRequestFooterView.isHidden == false - ) - self?.scrollButtonBottomConstraint?.isActive = ( - self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false - ) - - // Update the table content inset and offset to account for - // the dissapearance of the messageRequestsView - if messageRequestsViewWasVisible != (self?.messageRequestFooterView.isHidden == false) { - let messageRequestsOffset: CGFloat = (self?.messageRequestFooterView.bounds.height ?? 0) - let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) - self?.tableView.contentInset = UIEdgeInsets( - top: 0, - leading: 0, - bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), - trailing: 0 - ) - } - } - } - - if - initialLoad || - viewModel.threadData.outdatedMemberId != updatedThreadData.outdatedMemberId || - viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration - { - addOrRemoveOutdatedClientBanner( - outdatedMemberId: updatedThreadData.outdatedMemberId, - disappearingMessagesConfiguration: updatedThreadData.disappearingMessagesConfiguration - ) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.currentUserIsClosedGroupAdmin != updatedThreadData.currentUserIsClosedGroupAdmin - { - legacyGroupsBanner.isHidden = (updatedThreadData.threadVariant != .legacyGroup) - } - - if - initialLoad || - viewModel.threadData.threadVariant != updatedThreadData.threadVariant || - viewModel.threadData.closedGroupExpired != updatedThreadData.closedGroupExpired - { - expiredGroupBanner.isHidden = ( - updatedThreadData.threadVariant != .group || - updatedThreadData.closedGroupExpired != true - ) - } - if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { - updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) - } + addOrRemoveOutdatedClientBanner( + outdatedMemberId: state.threadViewModel.outdatedMemberId, + disappearingMessagesConfiguration: state.threadViewModel.disappearingMessagesConfiguration + ) - if initialLoad || viewModel.threadData.messageInputState != updatedThreadData.messageInputState { - snInputView.setMessageInputState(updatedThreadData.messageInputState) - } + legacyGroupsBanner.isHidden = (state.threadVariant != .legacyGroup) + expiredGroupBanner.isHidden = ( + state.threadVariant != .group || + state.threadViewModel.closedGroupExpired != true + ) + updateUnreadCountView(unreadCount: state.threadViewModel.threadUnreadCount) + snInputView.setMessageInputState(state.messageInputState) - // Only set the draft content on the initial load - if initialLoad, let draft: String = updatedThreadData.threadMessageDraft, !draft.isEmpty { + // Only set the draft content on the initial load (once we have data) + if !initialLoadComplete, let draft: String = state.threadViewModel.threadMessageDraft, !draft.isEmpty { let (string, mentions) = MentionUtilities.getMentions( in: draft, - currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), - displayNameRetriever: { [dependencies = viewModel.dependencies] sessionId, _ in - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return Profile.displayNameNoFallback( - id: sessionId, - threadVariant: updatedThreadData.threadVariant, - using: dependencies - ) + currentUserSessionIds: state.currentUserSessionIds, + displayNameRetriever: { sessionId, _ in + state.profileCache[sessionId]?.displayName(for: state.threadVariant) } ) snInputView.text = string @@ -933,88 +712,49 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Fetch the mention info asynchronously if !mentions.isEmpty { - viewModel.dependencies[singleton: .storage].readAsync( - retrieve: { db in - try Profile - .filter(ids: mentions.map { $0.profileId }) - .fetchAll(db) - }, - completion: { [weak self] result in - guard - let self = self, - case let .success(profiles) = result - else { return } - - self.mentions = self.mentions.appending( - contentsOf: profiles.map { - MentionInfo( - profile: $0, - threadVariant: updatedThreadData.threadVariant, - openGroupServer: updatedThreadData.openGroupServer, - openGroupRoomToken: updatedThreadData.openGroupRoomToken - ) - } - ) - } - ) + self.mentions = mentions.map { mention in + MentionInfo( + profile: (state.profileCache[mention.profileId] ?? Profile.defaultFor(mention.profileId)), + threadVariant: state.threadVariant, + openGroupServer: state.threadViewModel.openGroupServer, + openGroupRoomToken: state.threadViewModel.openGroupRoomToken + ) + } } } - // Now we have done all the needed diffs update the viewModel with the latest data - self.viewModel.updateThreadData(updatedThreadData) + // Update the table content + let updatedSections: [ConversationViewModel.SectionModel] = state.sections(viewModel: viewModel) - /// **Note:** This needs to happen **after** we have update the viewModel's thread data (otherwise the `inputAccessoryView` - /// won't be generated correctly) - if initialLoad || viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite { - if !self.isFirstResponder { - self.becomeFirstResponder() - } - else { - self.reloadInputViews() - } - } - } - - private func handleInteractionUpdates( - _ updatedData: [ConversationViewModel.SectionModel], - changeset: StagedChangeset<[ConversationViewModel.SectionModel]>, - initialLoad: Bool = false - ) { - // Determine if we have any messages for the empty state - let hasMessages: Bool = (updatedData - .filter { $0.model == .messages } - .first? - .elements - .isEmpty == false) - - // Ensure the first load or a load when returning from a child screen runs without - // animations (if we don't do this the cells will animate in from a frame of - // CGRect.zero or have a buggy transition) - guard self.hasLoadedInitialInteractionData else { - // Need to dispatch async to prevent this from causing glitches in the push animation - DispatchQueue.main.async { - self.viewModel.updateInteractionData(updatedData) - - // Update the empty state - self.emptyStateLabelContainer.isHidden = hasMessages - - UIView.performWithoutAnimation { - self.tableView.reloadData() - self.hasLoadedInitialInteractionData = true - self.performInitialScrollIfNeeded() - } + // Update the empty state + emptyStateLabel.themeAttributedText = state.emptyStateText.formatted(in: emptyStateLabel) + emptyStateLabelContainer.isHidden = (state.viewState != .empty) + + // If this is the initial load then just do a full table refresh + guard state.viewState == .loaded && initialLoadComplete else { + if state.viewState == .loaded { + sections = updatedSections + tableView.reloadData() + initialLoadComplete = true + performInitialScrollIfNeeded() /// Need to call after updating `initialLoadComplete` } return } - // Update the empty state - self.emptyStateLabelContainer.isHidden = hasMessages - // Update the ReactionListSheet (if one exists) - if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements { + if let messageUpdates: [MessageViewModel] = sections.first(where: { $0.model == .messages })?.elements { self.currentReactionListSheet?.handleInteractionUpdates(messageUpdates) } + // It's not the initial load so we should get a diff and may need to animate the change + let changeset: StagedChangeset = StagedChangeset( + source: sections, + target: updatedSections + ) + + // If there were no changes then no need to make changes to the table view + if changeset.isEmpty { return } + // Store the 'sentMessageBeforeUpdate' state locally let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate let onlyReplacedOptimisticUpdate: Bool = { @@ -1027,17 +767,21 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let deletedModels: [MessageViewModel] = changeset[changeset.count - 2] .elementDeleted - .map { self.viewModel.interactionData[$0.section].elements[$0.element] } + .map { self.sections[$0.section].elements[$0.element] } let insertedModels: [MessageViewModel] = changeset[changeset.count - 1] .elementInserted - .map { updatedData[$0.section].elements[$0.element] } + .map { updatedSections[$0.section].elements[$0.element] } - // Make sure all the deleted models were optimistic updates, the inserted models were not - // optimistic updates and they have the same timestamps + /// Make sure all the deleted models were optimistic updates, the inserted models were not optimistic updates and they + /// have the same `receivedAtTimestampMs` values + /// + /// **Note:** When sending a message to a Community conversation we replace the `timestampMs` with the server + /// timestamp so can't use that one as the "identifier", luckily the `receivedAtTimestampMs` is set at the time of creation + /// so it can be used return ( - deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] && - insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] && - deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet() + !deletedModels.contains { $0.optimisticMessageId == nil } && + !insertedModels.contains { $0.optimisticMessageId != nil } && + deletedModels.map { $0.receivedAtTimestampMs }.asSet() == insertedModels.map { $0.receivedAtTimestampMs }.asSet() ) }() let wasOnlyUpdates: Bool = ( @@ -1053,7 +797,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // but an instant update feels snappy and without the instant update there is some overlap of the read // status text change even though there shouldn't be any animations) guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else { - self.viewModel.updateInteractionData(updatedData) + sections = updatedSections self.tableView.reloadData() // If we just sent a message then we want to jump to the bottom of the conversation instantly @@ -1099,17 +843,17 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let isInsert: Bool = (numItemsInserted > 0) let wasLoadingMore: Bool = self.isLoadingMore let wasOffsetCloseToBottom: Bool = self.isCloseToBottom - let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } + let numItemsInUpdatedData: [Int] = updatedSections.map { $0.elements.count } let didSwapAllContent: Bool = { // The dynamic headers use negative id values so by using `compactMap` and returning // null in those cases allows us to exclude them without another iteration via `filter` - let currentIds: Set = (self.viewModel.interactionData + let currentIds: Set = (self.sections .first { $0.model == .messages }? .elements .compactMap { $0.id > 0 ? $0.id : nil } .asSet()) .defaulting(to: []) - let updatedIds: Set = (updatedData + let updatedIds: Set = (updatedSections .first { $0.model == .messages }? .elements .compactMap { $0.id > 0 ? $0.id : nil } @@ -1121,43 +865,41 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let itemChangeInfo: ItemChangeInfo = { guard isInsert, - let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), - let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), + let oldSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + let newSectionIndex: Int = updatedSections.firstIndex(where: { $0.model == .messages }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? .filter({ $0.section == oldSectionIndex && - self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader + self.sections[$0.section].elements[$0.row].cellType != .dateHeader }) .sorted() .first else { return ItemChangeInfo() } guard - let newFirstItemIndex: Int = updatedData[newSectionIndex].elements + let newFirstItemIndex: Int = updatedSections[newSectionIndex].elements .firstIndex(where: { item -> Bool in // Since the first item is probably a `DateHeaderCell` (which would likely // be removed when inserting items above it) we check if the id matches - let messages: [MessageViewModel] = self.viewModel - .interactionData[oldSectionIndex] - .elements + let messages: [MessageViewModel] = self.sections[oldSectionIndex].elements return ( item.id == messages[safe: 0]?.id || item.id == messages[safe: 1]?.id ) }), - let newVisibleIndex: Int = updatedData[newSectionIndex].elements + let newVisibleIndex: Int = updatedSections[newSectionIndex].elements .firstIndex(where: { item in - item.id == self.viewModel.interactionData[oldSectionIndex] + item.id == self.sections[oldSectionIndex] .elements[firstVisibleIndexPath.row] .id }) else { - let oldTimestamps: [Int64] = self.viewModel.interactionData[oldSectionIndex] + let oldTimestamps: [Int64] = self.sections[oldSectionIndex] .elements .filter { $0.cellType != .dateHeader } .map { $0.timestampMs } - let newTimestamps: [Int64] = updatedData[newSectionIndex] + let newTimestamps: [Int64] = updatedSections[newSectionIndex] .elements .filter { $0.cellType != .dateHeader } .map { $0.timestampMs } @@ -1183,7 +925,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa }() guard !isInsert || (!didSwapAllContent && itemChangeInfo.isInsertAtTop) else { - self.viewModel.updateInteractionData(updatedData) + sections = updatedSections self.tableView.reloadData() // If we had a focusedInteractionInfo then scroll to it (and hide the search @@ -1253,7 +995,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // sections/rows and then update the contentOffset self.tableView.afterNextLayoutSubviews( when: { numSections, numRowsInSections, _ -> Bool in - numSections == updatedData.count && + numSections == updatedSections.count && numRowsInSections == numItemsInUpdatedData }, then: { [weak self] in @@ -1320,24 +1062,22 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa reloadRowsAnimation: .none, interrupt: { itemChangeInfo.isInsertAtTop || $0.changeCount > ConversationViewModel.pageSize } ) { [weak self] updatedData in - self?.viewModel.updateInteractionData(updatedData) + self?.sections = updatedData } } // MARK: Updating private func performInitialScrollIfNeeded() { - guard !hasPerformedInitialScroll && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { - return - } + guard !hasPerformedInitialScroll && initialLoadComplete else { return } // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, // the screen will scroll to the bottom instead of the first unread message - if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo { + if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.state.focusedInteractionInfo { self.scrollToInteractionIfNeeded( with: focusedInteractionInfo, - focusBehaviour: self.viewModel.focusBehaviour, + focusBehaviour: self.viewModel.state.focusBehaviour, isAnimated: false ) } @@ -1357,7 +1097,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa private func autoLoadNextPageIfNeeded() { guard - self.hasLoadedInitialInteractionData && + self.initialLoadComplete && !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } @@ -1368,7 +1108,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self?.isAutoLoadingNextPage = false // Note: We sort the headers as we want to prioritise loading newer pages over older ones - let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData + let sections: [(ConversationViewModel.Section, CGRect)] = (self?.sections .enumerated() .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) .defaulting(to: []) @@ -1389,13 +1129,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self?.isLoadingMore = true - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // Attachments are loaded in descending order so 'loadOlder' actually corresponds with - // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? - .pageAfter : - .pageBefore - ) + // Messages are loaded in descending order so 'loadOlder' actually corresponds with + // 'loadPageAfter' in this case + if shouldLoadOlder { + self?.viewModel.loadPageAfter() + } + else { + self?.viewModel.loadPageBefore() } } } @@ -1508,7 +1248,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we explicitly can't write to the thread then the input will be hidden but they keyboard // still reports that it takes up size, so just report 0 height in that case - if viewModel.threadData.threadCanWrite == false && viewModel.threadData.threadVariant != .community { + if viewModel.state.threadViewModel.threadCanWrite == false && viewModel.state.threadVariant != .community { keyboardEndFrame = CGRect( x: UIScreen.main.bounds.minX, y: UIScreen.main.bounds.maxY, @@ -1585,7 +1325,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let insetDifference: CGFloat = (contentInsets.bottom - tableView.contentInset.bottom) scrollButtonBottomConstraint?.constant = -(bottomOffset + 12) messageRequestsViewBotomConstraint?.constant = -bottomOffset - legacyGroupsFooterViewViewTopConstraint?.constant = -(legacyGroupsFooterOffset + bottomOffset + (viewModel.threadData.threadCanWrite == false ? 16 : 0)) + legacyGroupsFooterViewViewTopConstraint?.constant = -(legacyGroupsFooterOffset + bottomOffset + (viewModel.state.threadViewModel.threadCanWrite == false ? 16 : 0)) tableView.contentInset = contentInsets tableView.scrollIndicatorInsets = contentInsets @@ -1608,7 +1348,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa outdatedMemberId: String?, disappearingMessagesConfiguration: DisappearingMessagesConfiguration? ) { - let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.threadData.disappearingMessagesConfiguration + let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.state.threadViewModel.disappearingMessagesConfiguration // Do not show the banner until the new disappearing messages is enabled guard currentDisappearingMessagesConfiguration?.isEnabled == true else { self.outdatedClientBanner.isHidden = true @@ -1638,14 +1378,13 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return } - let profileDispalyName: String = Profile.displayName( - id: outdatedMemberId, - threadVariant: self.viewModel.threadData.threadVariant, - using: viewModel.dependencies + let profileDisplayName: String = (viewModel.state.profileCache[outdatedMemberId] ?? Profile.defaultFor(outdatedMemberId)).displayName( + for: self.viewModel.state.threadVariant, + suppressId: true ) self.outdatedClientBanner.update( message: "disappearingMessagesLegacy" - .put(key: "name", value: profileDispalyName) + .put(key: "name", value: profileDisplayName) .localizedFormatted(baseFont: self.outdatedClientBanner.font), onTap: { [weak self] in self?.removeOutdatedClientBanner() } ) @@ -1658,7 +1397,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } private func removeOutdatedClientBanner() { - guard let outdatedMemberId: String = self.viewModel.threadData.outdatedMemberId else { return } + guard let outdatedMemberId: String = self.viewModel.state.threadViewModel.outdatedMemberId else { return } viewModel.dependencies[singleton: .storage].writeAsync { db in try Contact @@ -1679,17 +1418,15 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.interactionData.count + return sections.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] - - return section.elements.count + return sections[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section] + let section: ConversationViewModel.SectionModel = sections[indexPath.section] switch section.model { case .messages: @@ -1733,7 +1470,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { case .loadOlder, .loadNewer: @@ -1755,7 +1492,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { case .loadOlder, .loadNewer: return ConversationVC.loadingHeaderHeight @@ -1766,55 +1503,53 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { guard self.hasPerformedInitialScroll && !self.isLoadingMore else { return } - let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section] + let section: ConversationViewModel.SectionModel = sections[section] switch section.model { - case .loadOlder, .loadNewer: + case .messages: break + case .loadOlder: self.isLoadingMore = true + self.viewModel.loadPageBefore() - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // Messages are loaded in descending order so 'loadOlder' actually corresponds with - // 'pageAfter' in this case - self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? - .pageAfter : - .pageBefore - ) - } - - case .messages: break + case .loadNewer: + self.isLoadingMore = true + self.viewModel.loadPageAfter() } } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + /// Don't mark anything as read until after the initial layout because we already mark the "initially focussed" message as read + guard self.didFinishInitialLayout else { return } + + self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil) + } func scrollToBottom(isAnimated: Bool) { guard !self.isUserScrolling, - let messagesSectionIndex: Int = self.viewModel.interactionData - .firstIndex(where: { $0.model == .messages }), - !self.viewModel.interactionData[messagesSectionIndex] - .elements - .isEmpty + let messagesSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + !self.sections[messagesSectionIndex].elements.isEmpty else { return } // If the last interaction isn't loaded then scroll to the final interactionId on // the thread data - let hasNewerItems: Bool = self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) + let hasNewerItems: Bool = self.sections.contains(where: { $0.model == .loadNewer }) + let messages: [MessageViewModel] = self.sections[messagesSectionIndex].elements + let lastInteractionInfo: Interaction.TimestampInfo = { + guard + let interactionId: Int64 = self.viewModel.state.threadViewModel.interactionId, + let timestampMs: Int64 = self.viewModel.state.threadViewModel.interactionTimestampMs + else { + return Interaction.TimestampInfo( + id: messages[messages.count - 1].id, + timestampMs: messages[messages.count - 1].timestampMs + ) + } + + return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs) + }() guard !self.didFinishInitialLayout || !hasNewerItems else { - let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements - let lastInteractionInfo: Interaction.TimestampInfo = { - guard - let interactionId: Int64 = self.viewModel.threadData.interactionId, - let timestampMs: Int64 = self.viewModel.threadData.interactionTimestampMs - else { - return Interaction.TimestampInfo( - id: messages[messages.count - 1].id, - timestampMs: messages[messages.count - 1].timestampMs - ) - } - - return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs) - }() - self.scrollToInteractionIfNeeded( with: lastInteractionInfo, position: .bottom, @@ -1824,7 +1559,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } let targetIndexPath: IndexPath = IndexPath( - row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), + row: (sections[messagesSectionIndex].elements.count - 1), section: messagesSectionIndex ) self.tableView.scrollToRow( @@ -1833,10 +1568,12 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa animated: isAnimated ) - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: nil), - timestampMs: nil - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.viewModel.markAsReadIfNeeded( + interactionInfo: lastInteractionInfo, + visibleViewModelRetriever: nil + ) + } } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -1849,12 +1586,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrollToBottom() - - // The initial scroll can trigger this logic but we already mark the initially focused message - // as read so don't run the below until the user actually scrolls after the initial layout - guard self.didFinishInitialLayout else { return } - - self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { @@ -1890,7 +1621,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the // 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without // this the button will fade out as the user gets close to the bottom of the current page) - guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else { + guard !self.sections.contains(where: { $0.model == .loadNewer }) else { self.scrollButton.alpha = 1 self.unreadCountView.alpha = 1 return @@ -1958,10 +1689,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Nav bar buttons updateNavBarButtons( - threadData: viewModel.threadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + threadData: viewModel.state.threadViewModel, + initialVariant: viewModel.state.threadVariant, + initialIsNoteToSelf: viewModel.state.threadViewModel.threadIsNoteToSelf, + initialIsBlocked: (viewModel.state.threadViewModel.threadIsBlocked == true) ) // Hack so that the ResultsBar stays on the screen when dismissing the search field @@ -1998,10 +1729,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa isShowingSearchUI = false navigationItem.titleView = titleView updateNavBarButtons( - threadData: viewModel.threadData, - initialVariant: viewModel.initialThreadVariant, - initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, - initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + threadData: viewModel.state.threadViewModel, + initialVariant: viewModel.state.threadVariant, + initialIsNoteToSelf: viewModel.state.threadViewModel.threadIsNoteToSelf, + initialIsBlocked: (viewModel.state.threadViewModel.threadIsBlocked == true) ) searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil @@ -2045,9 +1776,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Ensure the target interaction has been loaded guard - let messageSectionIndex: Int = self.viewModel.interactionData - .firstIndex(where: { $0.model == .messages }), - let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] + let messageSectionIndex: Int = self.sections.firstIndex(where: { $0.model == .messages }), + let targetMessageIndex = self.sections[messageSectionIndex] .elements .firstIndex(where: { $0.id == interactionInfo.id }) else { @@ -2057,10 +1787,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.isLoadingMore = true self.searchController.resultsBar.startLoading() - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.jumpTo(id: interactionInfo.id, padding: 5)) - } + self.viewModel.jumpToPage(for: interactionInfo.id, padding: 5) return } @@ -2070,7 +1797,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa guard !self.didFinishInitialLayout && targetMessageIndex > 0 && - self.viewModel.interactionData[messageSectionIndex] + self.sections[messageSectionIndex] .elements[targetMessageIndex - 1] .cellType == .unreadMarker else { @@ -2163,7 +1890,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } case .earlier: - let targetRow: Int = min(targetIndexPath.row + 10, self.viewModel.interactionData[messageSectionIndex].elements.count - 1) + let targetRow: Int = min(targetIndexPath.row + 10, self.sections[messageSectionIndex].elements.count - 1) self.tableView.contentOffset = CGPoint(x: 0, y: self.tableView.rectForRow(at: IndexPath(row: targetRow, section: targetIndexPath.section)).midY) @@ -2176,7 +1903,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true) } - func fullyVisibleCellViewModels() -> [MessageViewModel]? { + @MainActor func fullyVisibleCellViewModels() -> [MessageViewModel]? { // We remove the 'Values.mediumSpacing' as that is the distance the table content appears above the input view let tableVisualTop: CGFloat = tableView.frame.minY let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing)) @@ -2184,7 +1911,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa guard let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows, let messagesSection: Int = visibleIndexPaths - .first(where: { self.viewModel.interactionData[$0.section].model == .messages })? + .first(where: { self.sections[$0.section].model == .messages })? .section else { return nil } @@ -2198,7 +1925,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa case is VisibleMessageCell, is CallMessageCell, is InfoMessageCell: return ( view.convert(cell.frame, from: tableView), - self.viewModel.interactionData[indexPath.section].elements[indexPath.row] + self.sections[indexPath.section].elements[indexPath.row] ) case is TypingIndicatorCell, is DateHeaderCell, is UnreadMarkerCell: @@ -2215,28 +1942,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) { - // Only retrieve the `fullyVisibleCellViewModels` if the viewModel things we should mark something as read - guard self.viewModel.shouldTryMarkAsRead() else { return } - - // We want to mark messages as read on load and while we scroll, so grab the newest message and mark - // everything older as read - guard let newestCellViewModel: MessageViewModel = fullyVisibleCellViewModels()?.last else { - // If we weren't able to get any visible cells for some reason then we should fall back to - // marking the provided interactionInfo as read just in case - if let interactionInfo: Interaction.TimestampInfo = interactionInfo { - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id), - timestampMs: interactionInfo.timestampMs - ) + Task { [weak self] in + await self?.viewModel.markAsReadIfNeeded(interactionInfo: interactionInfo) { + self?.fullyVisibleCellViewModels() } - return } - - // Mark all interactions before the newest entirely-visible one as read - self.viewModel.markAsRead( - target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id), - timestampMs: newestCellViewModel.timestampMs - ) } func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) { @@ -2259,6 +1969,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - LibSessionRespondingViewController func isConversation(in threadIds: [String]) -> Bool { - return threadIds.contains(self.viewModel.threadData.threadId) + return threadIds.contains(self.viewModel.state.threadId) } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index f16ae02b4c..39b7fd8de2 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -24,7 +24,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - FocusBehaviour - public enum FocusBehaviour { + public enum FocusBehaviour: Sendable, Equatable, Hashable { case none case highlight } @@ -54,671 +54,1179 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case loadNewer } + // MARK: - OptimisticMessageData + + public struct OptimisticMessageData: Sendable, Equatable, Hashable { + let temporaryId: Int64 + let interaction: Interaction + let attachmentData: [Attachment]? + let linkPreviewDraft: LinkPreviewDraft? + let linkPreviewPreparedAttachment: PreparedAttachment? + let quoteModel: QuotedReplyModel? + } + // MARK: - Variables public static let pageSize: Int = 50 + public static let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public let navigatableState: NavigatableState = NavigatableState() public var disposables: Set = Set() - private var threadId: String - public let initialThreadVariant: SessionThread.Variant + public let dependencies: Dependencies public var sentMessageBeforeUpdate: Bool = false public var lastSearchedText: String? - public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search - public let focusBehaviour: FocusBehaviour - private let initialUnreadInteractionId: Int64? - private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject() - private var markAsReadPublisher: AnyPublisher? - public let dependencies: Dependencies + // FIXME: Can avoid this by making the view model an actor (but would require more work) + /// Marked as `@MainActor` just to force thread safety + @MainActor private var pendingMarkAsReadInfo: Interaction.TimestampInfo? + @MainActor private var lastMarkAsReadInfo: Interaction.TimestampInfo? + + /// This value is the current state of the view + @MainActor @Published private(set) var state: State + private var observationTask: Task? + + // TODO: [PRO] Remove this value (access via `state`) public var isCurrentUserSessionPro: Bool { dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro } - public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) - public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { - let localizationKey: String + // MARK: - Initialization + + @MainActor init( + threadViewModel: SessionThreadViewModel, + focusedInteractionInfo: Interaction.TimestampInfo? = nil, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.state = State.initialState( + threadViewModel: threadViewModel, + focusedInteractionInfo: focusedInteractionInfo, + using: dependencies + ) - switch threadData.currentUserIsClosedGroupAdmin == true { - case false: localizationKey = "legacyGroupAfterDeprecationMember" - case true: localizationKey = "legacyGroupAfterDeprecationAdmin" + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.state) + .debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them + .using(dependencies: dependencies) + .query(ConversationViewModel.queryState) + .assign { [weak self] updatedState in self?.state = updatedState } + } + + deinit { + // Stop any audio playing when leaving the screen + Task { @MainActor [audioPlayer] in + audioPlayer?.stop() } - // FIXME: Strings should be updated in Crowdin to include the {icon} - return LocalizationHelper(template: localizationKey) - .put(key: "date", value: Date(timeIntervalSince1970: 1743631200).formattedForBanner) - .localizedFormatted(baseFont: legacyGroupsBannerFont) - .appending(string: " ") // Designs have a space before the icon - .appending(Lucide.Icon.squareArrowUpRight.attributedString(for: legacyGroupsBannerFont)) - .appending(string: " ") // In case it's a RTL font - }() + observationTask?.cancel() + } + + public enum ConversationViewModelEvent: Hashable { + case sendMessage(data: OptimisticMessageData) + case failedToStoreMessage(temporaryId: Int64) + case resolveOptimisticMessage(temporaryId: Int64, databaseId: Int64) + } - public lazy var blockedBannerMessage: String = { - let threadData: SessionThreadViewModel = self.internalThreadData + // MARK: - State + + public struct State: ObservableKeyProvider { + enum ViewState: Equatable { + case loading + case empty + case loaded + } - switch threadData.threadVariant { - case .contact: - let name: String = Profile.displayName( - id: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ) + let viewState: ViewState + let threadId: String + let threadVariant: SessionThread.Variant + let userSessionId: SessionId + let currentUserSessionIds: Set + let isBlindedContact: Bool + let wasPreviouslyBlindedContact: Bool + + /// Used to determine where the paged data should start loading from, and which message should be focused on initial load + let focusedInteractionInfo: Interaction.TimestampInfo? + let focusBehaviour: FocusBehaviour + let initialUnreadInteractionInfo: Interaction.TimestampInfo? + + let loadedPageInfo: PagedData.LoadedInfo + let profileCache: [String: Profile] + var linkPreviewCache: [String: [LinkPreview]] + let interactionCache: [Int64: Interaction] + let attachmentCache: [String: Attachment] + let reactionCache: [Int64: [Reaction]] + let quoteMap: [Int64: Int64] + let attachmentMap: [Int64: Set] + let modAdminCache: Set + let itemCache: [Int64: MessageViewModel] + + let titleViewModel: ConversationTitleViewModel + let threadViewModel: SessionThreadViewModel + let threadContact: Contact? + let threadIsTrusted: Bool + let legacyGroupsBannerIsVisible: Bool + let reactionsSupported: Bool + let isUserModeratorOrAdmin: Bool + let shouldShowTypingIndicator: Bool + + let optimisticallyInsertedMessages: [Int64: OptimisticMessageData] + + var emptyStateText: String { + let blocksCommunityMessageRequests: Bool = (threadViewModel.profile?.blocksCommunityMessageRequests == true) + + switch (threadViewModel.threadIsNoteToSelf, threadViewModel.threadCanWrite == true, blocksCommunityMessageRequests, threadViewModel.wasKickedFromGroup, threadViewModel.groupIsDestroyed) { + case (true, _, _, _, _): return "noteToSelfEmpty".localized() + case (_, false, true, _, _): + return "messageRequestsTurnedOff" + .put(key: "name", value: threadViewModel.displayName) + .localized() - return "blockBlockedDescription".localized() + case (_, _, _, _, true): + return "groupDeletedMemberDescription" + .put(key: "group_name", value: threadViewModel.displayName) + .localized() + + case (_, _, _, true, _): + return "groupRemovedYou" + .put(key: "group_name", value: threadViewModel.displayName) + .localized() + + case (_, false, false, _, _): + return "conversationsEmpty" + .put(key: "conversation_name", value: threadViewModel.displayName) + .localized() - default: return "blockUnblock".localized() // Should not happen + default: + return "groupNoMessages" + .put(key: "group_name", value: threadViewModel.displayName) + .localized() + } + } + + var legacyGroupsBannerMessage: ThemedAttributedString { + let localizationKey: String + + switch threadViewModel.currentUserIsClosedGroupAdmin == true { + case false: localizationKey = "legacyGroupAfterDeprecationMember" + case true: localizationKey = "legacyGroupAfterDeprecationAdmin" + } + + // FIXME: Strings should be updated in Crowdin to include the {icon} + return LocalizationHelper(template: localizationKey) + .put(key: "date", value: Date(timeIntervalSince1970: 1743631200).formattedForBanner) + .localizedFormatted(baseFont: ConversationViewModel.legacyGroupsBannerFont) + .appending(string: " ") // Designs have a space before the icon + .appending( + Lucide.Icon.squareArrowUpRight + .attributedString(for: ConversationViewModel.legacyGroupsBannerFont) + ) + .appending(string: " ") // In case it's a RTL font + } + + var messageInputState: SessionThreadViewModel.MessageInputState { + guard !threadViewModel.threadIsNoteToSelf else { + return SessionThreadViewModel.MessageInputState(allowedInputTypes: .all) + } + guard threadViewModel.threadIsBlocked != true else { + return SessionThreadViewModel.MessageInputState( + allowedInputTypes: .none, + message: "blockBlockedDescription".localized(), + messageAccessibility: Accessibility( + identifier: "Blocked banner" + ) + ) + } + + if threadViewModel.threadVariant == .community && threadViewModel.threadCanWrite == false { + return SessionThreadViewModel.MessageInputState( + allowedInputTypes: .none, + message: "permissionsWriteCommunity".localized() + ) + } + + return SessionThreadViewModel.MessageInputState( + allowedInputTypes: (threadViewModel.threadRequiresApproval == false && threadViewModel.threadIsMessageRequest == false ? + .all : + .textOnly + ) + ) + } + + @MainActor public func sections(viewModel: ConversationViewModel) -> [SectionModel] { + ConversationViewModel.sections(state: self, viewModel: viewModel) + } + + public var observedKeys: Set { + var result: Set = [ + .loadPage(ConversationViewModel.self), + .updateScreen(ConversationViewModel.self), + .conversationUpdated(threadId), + .conversationDeleted(threadId), + .profile(userSessionId.hexString), + .typingIndicator(threadId), + .messageCreated(threadId: threadId) + ] + + /// Add thread-variant specific events (eg. ensure the display picture and title change when profiles are updated, initial + /// data is loaded, etc.) + switch threadViewModel.threadVariant { + case .contact: + result.insert(.profile(threadViewModel.threadId)) + result.insert(.contact(threadViewModel.threadId)) + + case .group: + if let frontProfileId: String = threadViewModel.closedGroupProfileFront?.id { + result.insert(.profile(frontProfileId)) + } + + if let backProfileId: String = threadViewModel.closedGroupProfileBack?.id { + result.insert(.profile(backProfileId)) + } + + case .community: + result.insert(.communityUpdated(threadId)) + + default: break + } + + interactionCache.keys.forEach { messageId in + result.insert(.messageUpdated(id: messageId, threadId: threadId)) + result.insert(.messageDeleted(id: messageId, threadId: threadId)) + result.insert(.reactionsChanged(messageId: messageId)) + result.insert(.attachmentCreated(messageId: messageId)) + + attachmentMap[messageId]?.forEach { interactionAttachment in + result.insert(.attachmentUpdated(id: interactionAttachment.attachmentId, messageId: messageId)) + result.insert(.attachmentDeleted(id: interactionAttachment.attachmentId, messageId: messageId)) + } + } + + return result } - }() - - // MARK: - Initialization - // TODO: [Database Relocation] Initialise this with the thread data from the home screen (might mean we can avoid some of the `initialData` query? - init( - threadId: String, - threadVariant: SessionThread.Variant, - focusedInteractionInfo: Interaction.TimestampInfo?, - using dependencies: Dependencies - ) { - typealias InitialData = ( - userSessionId: SessionId, - initialUnreadInteractionInfo: Interaction.TimestampInfo?, - threadIsBlocked: Bool, - threadIsMessageRequest: Bool, - closedGroupAdminProfile: Profile?, - currentUserIsClosedGroupMember: Bool?, - currentUserIsClosedGroupAdmin: Bool?, - openGroupPermissions: OpenGroup.Permissions?, - threadWasMarkedUnread: Bool, - currentUserSessionIds: Set - ) - let initialData: InitialData? = dependencies[singleton: .storage].read { db -> InitialData in - let interaction: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() + static func initialState( + threadViewModel: SessionThreadViewModel, + focusedInteractionInfo: Interaction.TimestampInfo?, + using dependencies: Dependencies + ) -> State { let userSessionId: SessionId = dependencies[cache: .general].sessionId - // If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest - // unread interaction and start focused around that one - let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction - .select(.id, .timestampMs) - .filter(interaction[.wasRead] == false) - .filter(interaction[.threadId] == threadId) - .order(interaction[.timestampMs].asc) - .asRequest(of: Interaction.TimestampInfo.self) - .fetchOne(db) - let threadIsBlocked: Bool = (threadVariant != .contact ? false : - try Contact - .filter(id: threadId) - .select(.isBlocked) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: false) + return State( + viewState: .loading, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + userSessionId: userSessionId, + currentUserSessionIds: [userSessionId.hexString], + isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), + wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), + focusedInteractionInfo: focusedInteractionInfo, + focusBehaviour: (focusedInteractionInfo == nil ? .none : .highlight), + initialUnreadInteractionInfo: nil, + loadedPageInfo: PagedData.LoadedInfo( + record: Interaction.self, + pageSize: ConversationViewModel.pageSize, + requiredJoinSQL: nil, + filterSQL: MessageViewModel.interactionFilterSQL(threadId: threadViewModel.threadId), + groupSQL: nil, + orderSQL: MessageViewModel.interactionOrderSQL + ), + profileCache: [:], + linkPreviewCache: [:], + interactionCache: [:], + attachmentCache: [:], + reactionCache: [:], + quoteMap: [:], + attachmentMap: [:], + modAdminCache: [], + itemCache: [:], + titleViewModel: ConversationTitleViewModel( + threadViewModel: threadViewModel, + using: dependencies + ), + threadViewModel: threadViewModel, + threadContact: nil, + threadIsTrusted: false, + legacyGroupsBannerIsVisible: (threadViewModel.threadVariant == .legacyGroup), + reactionsSupported: ( + threadViewModel.threadVariant != .legacyGroup && + threadViewModel.threadIsMessageRequest != true + ), + isUserModeratorOrAdmin: false, + shouldShowTypingIndicator: false, + optimisticallyInsertedMessages: [:] ) - let threadIsMessageRequest: Bool = try { - switch threadVariant { - case .contact: - let isApproved: Bool = try Contact - .filter(id: threadId) - .select(.isApproved) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: true) - - return !isApproved + } + + fileprivate static func orderedIdsIncludingOptimisticMessages( + loadedPageInfo: PagedData.LoadedInfo, + optimisticMessages: [Int64: OptimisticMessageData], + interactionCache: [Int64: Interaction] + ) -> [Int64] { + guard !optimisticMessages.isEmpty else { return loadedPageInfo.currentIds } + + /// **Note:** The sorting of `currentIds` is newest to oldest so we need to insert in the same way + var remainingPagedIds: [Int64] = loadedPageInfo.currentIds + var remainingSortedOptimisticMessages: [(Int64, OptimisticMessageData)] = optimisticMessages + .sorted { lhs, rhs in + lhs.value.interaction.timestampMs > rhs.value.interaction.timestampMs + } + var result: [Int64] = [] + + while !remainingPagedIds.isEmpty || !remainingSortedOptimisticMessages.isEmpty { + let nextPaged: Interaction? = remainingPagedIds.first.map { interactionCache[$0] } + let nextOptimistic: OptimisticMessageData? = remainingSortedOptimisticMessages.first?.1 + + switch (nextPaged, nextOptimistic) { + case (.some(let paged), .some(let optimistic)): /// Add the newest first and loop + if optimistic.interaction.timestampMs >= paged.timestampMs { + result.append(optimistic.temporaryId) + remainingSortedOptimisticMessages.removeFirst() + } + else { + paged.id.map { result.append($0) } + remainingPagedIds.removeFirst() + } - case .group: - let isInvite: Bool = try ClosedGroup - .filter(id: threadId) - .select(.invited) - .asRequest(of: Bool.self) - .fetchOne(db) - .defaulting(to: true) + case (.some, .none): /// No optimistic messages left, add the remaining paged messages + result.append(contentsOf: remainingPagedIds) + remainingPagedIds.removeAll() - return !isInvite + case (.none, .some): /// No paged results left, add the remaining optimistic messages + result.append(contentsOf: remainingSortedOptimisticMessages.map { $0.0 }) + remainingSortedOptimisticMessages.removeAll() - default: return false + case (.none, .none): return result /// Invalid case } - }() + } - let closedGroupAdminProfile: Profile? = (threadVariant != .group ? nil : - try Profile - .joining( - required: Profile.groupMembers - .filter(GroupMember.Columns.groupId == threadId) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - ) - .fetchOne(db) - ) - let currentUserIsClosedGroupAdmin: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil : - GroupMember - .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == userSessionId.hexString) - .filter(groupMember[.role] == GroupMember.Role.admin) - .isNotEmpty(db) - ) - let currentUserIsClosedGroupMember: Bool? = { - guard [.legacyGroup, .group].contains(threadVariant) else { return nil } - guard currentUserIsClosedGroupAdmin != true else { return true } - - return GroupMember - .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == userSessionId.hexString) - .filter(groupMember[.role] == GroupMember.Role.standard) - .isNotEmpty(db) - }() - let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil : - try OpenGroup - .filter(id: threadId) - .select(.permissions) - .asRequest(of: OpenGroup.Permissions.self) - .fetchOne(db) - ) - let threadWasMarkedUnread: Bool = (try? SessionThread - .filter(id: threadId) - .select(.markedAsUnread) - .asRequest(of: Bool.self) - .fetchOne(db)) - .defaulting(to: false) - var currentUserSessionIds: Set = Set([userSessionId.hexString]) + return result + } + + fileprivate static func interaction( + at index: Int, + orderedIds: [Int64], + optimisticMessages: [Int64: OptimisticMessageData], + interactionCache: [Int64: Interaction] + ) -> Interaction? { + guard index >= 0, index < orderedIds.count else { return nil } + guard orderedIds[index] >= 0 else { + /// If the `id` is less than `0` then it's an optimistic message + return optimisticMessages[orderedIds[index]]?.interaction + } - if - threadVariant == .community, - let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, id: threadId) - { - currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString) - currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString) + return interactionCache[orderedIds[index]] + } + } + + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + var threadId: String = previousState.threadId + let threadVariant: SessionThread.Variant = previousState.threadVariant + var currentUserSessionIds: Set = previousState.currentUserSessionIds + var focusedInteractionInfo: Interaction.TimestampInfo? = previousState.focusedInteractionInfo + var initialUnreadInteractionInfo: Interaction.TimestampInfo? = previousState.initialUnreadInteractionInfo + var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult + var profileCache: [String: Profile] = previousState.profileCache + var linkPreviewCache: [String: [LinkPreview]] = previousState.linkPreviewCache + var interactionCache: [Int64: Interaction] = previousState.interactionCache + var attachmentCache: [String: Attachment] = previousState.attachmentCache + var reactionCache: [Int64: [Reaction]] = previousState.reactionCache + var quoteMap: [Int64: Int64] = previousState.quoteMap + var attachmentMap: [Int64: Set] = previousState.attachmentMap + var modAdminCache: Set = previousState.modAdminCache + var itemCache: [Int64: MessageViewModel] = previousState.itemCache + var threadViewModel: SessionThreadViewModel = previousState.threadViewModel + var threadContact: Contact? = previousState.threadContact + var threadIsTrusted: Bool = previousState.threadIsTrusted + var reactionsSupported: Bool = previousState.reactionsSupported + var isUserModeratorOrAdmin: Bool = previousState.isUserModeratorOrAdmin + var threadWasKickedFromGroup: Bool = (threadViewModel.wasKickedFromGroup == true) + var threadGroupIsDestroyed: Bool = (threadViewModel.groupIsDestroyed == true) + var shouldShowTypingIndicator: Bool = false + var optimisticallyInsertedMessages: [Int64: OptimisticMessageData] = previousState.optimisticallyInsertedMessages + + /// Store a local copy of the events so we can manipulate it based on the state changes + var eventsToProcess: [ObservedEvent] = events + var profileIdsNeedingFetch: Set = [] + var shouldFetchInitialUnreadInteractionInfo: Bool = false + + /// If this is the initial query then we need to properly fetch the initial state + if isInitialQuery { + /// Insert a fake event to force the initial page load + eventsToProcess.append(ObservedEvent( + key: .loadPage(ConversationViewModelEvent.self), + value: ( + focusedInteractionInfo.map { LoadPageEvent.initialPageAround(id: $0.id) } ?? + LoadPageEvent.initial + ) + )) + + /// Determine reactions support + switch threadVariant { + case .legacyGroup: + reactionsSupported = false + isUserModeratorOrAdmin = (threadViewModel.currentUserIsClosedGroupAdmin == true) + + case .contact: + reactionsSupported = (threadViewModel.threadIsMessageRequest != true) + shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators] + .isRecipientTyping(threadId: threadId) + + case .group: + reactionsSupported = (threadViewModel.threadIsMessageRequest != true) + isUserModeratorOrAdmin = (threadViewModel.currentUserIsClosedGroupAdmin == true) + + case .community: + reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport( + capability: .reactions, + on: threadViewModel.openGroupServer + ) + + /// Get the session id options for the current user + if + let server: String = threadViewModel.openGroupServer, + let serverInfo: CommunityManager.Server = await dependencies[singleton: .communityManager].server(server) + { + currentUserSessionIds = serverInfo.currentUserSessionIds + } + + modAdminCache = await dependencies[singleton: .communityManager].allModeratorsAndAdmins( + server: threadViewModel.openGroupServer, + roomToken: threadViewModel.openGroupRoomToken, + includingHidden: true + ) + isUserModeratorOrAdmin = !modAdminCache.isDisjoint(with: currentUserSessionIds) } - return ( - userSessionId, - initialUnreadInteractionInfo, - threadIsBlocked, - threadIsMessageRequest, - closedGroupAdminProfile, - currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin, - openGroupPermissions, - threadWasMarkedUnread, - currentUserSessionIds + /// Determine whether we need to fetch the initial unread interaction info + shouldFetchInitialUnreadInteractionInfo = (initialUnreadInteractionInfo == nil) + + /// Check if the typing indicator should be visible + shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators].isRecipientTyping( + threadId: threadId ) } - self.threadId = threadId - self.initialThreadVariant = threadVariant - self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) - self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight) - self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id - self.internalThreadData = SessionThreadViewModel( - threadId: threadId, - threadVariant: threadVariant, - threadIsNoteToSelf: (initialData?.userSessionId.hexString == threadId), - threadIsMessageRequest: initialData?.threadIsMessageRequest, - threadIsBlocked: initialData?.threadIsBlocked, - closedGroupAdminProfile: initialData?.closedGroupAdminProfile, - currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: initialData?.currentUserIsClosedGroupAdmin, - openGroupPermissions: initialData?.openGroupPermissions, - threadWasMarkedUnread: initialData?.threadWasMarkedUnread, - using: dependencies - ) - .populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: ( - initialData?.currentUserSessionIds ?? - [dependencies[cache: .general].sessionId.hexString] - ), - wasKickedFromGroup: ( - threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) - } - ), - groupIsDestroyed: ( - threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + /// If there are no events we want to process then just return the current state + guard !eventsToProcess.isEmpty else { return previousState } + + /// Split the events between those that need database access and those that don't + let splitEvents: [EventDataRequirement: Set] = eventsToProcess + .reduce(into: [:]) { result, next in + switch next.dataRequirement { + case .databaseQuery: result[.databaseQuery, default: []].insert(next) + case .other: result[.other, default: []].insert(next) + case .bothDatabaseQueryAndOther: + result[.databaseQuery, default: []].insert(next) + result[.other, default: []].insert(next) } - ), - threadCanWrite: true, // Assume true - threadCanUpload: true // Assume true - ) - self.pagedDataObserver = nil - self.dependencies = dependencies + } + var databaseEvents: Set = (splitEvents[.databaseQuery] ?? []) + let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? + .reduce(into: [:]) { result, event in + result[event.key.generic, default: []].insert(event) + } + var loadPageEvent: LoadPageEvent? = splitEvents[.databaseQuery]? + .first(where: { $0.key.generic == .loadPage })? + .value as? LoadPageEvent - // Note: Since this references self we need to finish initializing before setting it, we - // also want to skip the initial query and trigger it async so that the push animation - // doesn't stutter (it should load basically immediately but without this there is a - // distinct stutter) - self.pagedDataObserver = self.setupPagedObserver( - for: threadId, - userSessionId: (initialData?.userSessionId ?? dependencies[cache: .general].sessionId), - currentUserSessionIds: ( - initialData?.currentUserSessionIds ?? - [dependencies[cache: .general].sessionId.hexString] - ), - using: dependencies + // FIXME: We should be able to make this far more efficient by splitting this query up and only fetching diffs + var threadNeedsRefresh: Bool = ( + threadId != previousState.threadId || + events.contains(where: { + $0.key.generic == .conversationUpdated || + $0.key.generic == .contact || + $0.key.generic == .profile + }) ) - // Run the initial query on a background thread so we don't block the push transition - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query - // from a `0` offset) - switch (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) { - case .some(let info): self?.pagedDataObserver?.load(.initialPageAround(id: info.id)) - case .none: self?.pagedDataObserver?.load(.pageBefore) + /// Handle thread specific changes first (as this could include a conversation being unblinded) + switch threadVariant { + case .contact: + groupedOtherEvents?[.contact]?.forEach { event in + guard let eventValue: ContactEvent = event.value as? ContactEvent else { return } + + switch eventValue.change { + case .isTrusted(let value): + threadContact = threadContact?.with( + isTrusted: .set(to: value), + currentUserSessionId: previousState.userSessionId + ) + + case .isApproved(let value): + threadContact = threadContact?.with( + isApproved: .set(to: value), + currentUserSessionId: previousState.userSessionId + ) + + case .isBlocked(let value): + threadContact = threadContact?.with( + isBlocked: .set(to: value), + currentUserSessionId: previousState.userSessionId + ) + + case .didApproveMe(let value): + threadContact = threadContact?.with( + didApproveMe: .set(to: value), + currentUserSessionId: previousState.userSessionId + ) + + case .unblinded(let blindedId, let unblindedId): + /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then + /// we reload the messages based on the initial paged data query just in case - there isn't a perfect + /// solution to capture the current messages plus any others that may have been added by the + /// merge so do the best we can) + guard blindedId == threadId else { return } + + threadId = unblindedId + loadResult = loadResult.info + .with(filterSQL: MessageViewModel.interactionFilterSQL(threadId: unblindedId)) + .asResult + loadPageEvent = .initial + databaseEvents.insert( + ObservedEvent( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.initial + ) + ) + } + } + + case .legacyGroup, .group: + groupedOtherEvents?[.groupMemberUpdated]?.forEach { event in + guard let eventValue: GroupMemberEvent = event.value as? GroupMemberEvent else { return } + + switch eventValue.change { + case .none: break + case .role(let role, _): + guard eventValue.profileId == previousState.userSessionId.hexString else { return } + + isUserModeratorOrAdmin = (role == .admin) + } + } + + case .community: + /// Handle community changes (users could change to mods which would need all of their interaction data updated) + groupedOtherEvents?[.communityUpdated]?.forEach { event in + guard let eventValue: CommunityEvent = event.value as? CommunityEvent else { return } + + switch eventValue.change { + case .receivedInitialMessages: + /// If we already have a `loadPageEvent` then that takes prescedence, otherwise we should load + /// the initial page once we've received the initial messages for a community + guard loadPageEvent == nil else { break } + + loadPageEvent = .initial + + case .role(let moderator, let admin, let hiddenModerator, let hiddenAdmin): + isUserModeratorOrAdmin = (moderator || admin || hiddenModerator || hiddenAdmin) + + case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): + modAdminCache = Set(admins + hiddenAdmins + moderators + hiddenModerators) + isUserModeratorOrAdmin = !modAdminCache.isDisjoint(with: currentUserSessionIds) + + // FIXME: When we break apart the SessionThreadViewModel these should be handled + case .capabilities, .permissions: break + } + } + } + + /// Profile events + groupedOtherEvents?[.profile]?.forEach { event in + guard let eventValue: ProfileEvent = event.value as? ProfileEvent else { return } + guard var profileData: Profile = profileCache[eventValue.id] else { + /// This profile (somehow) isn't in the cache, so we need to fetch it + profileIdsNeedingFetch.insert(eventValue.id) + return + } + + switch eventValue.change { + case .name(let name): profileData = profileData.with(name: name) + case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) } + + profileCache[eventValue.id] = profileData } - } - - deinit { - // Stop any audio playing when leaving the screen - stopAudio() - } - - // MARK: - Thread Data - - @ThreadSafe private var internalThreadData: SessionThreadViewModel - - /// This value is the current state of the view - public var threadData: SessionThreadViewModel { internalThreadData } - - /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise - /// performance https://github.com/groue/GRDB.swift#valueobservation-performance - /// - /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static - /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries - /// - /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) - /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own - /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) - /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this - public typealias ThreadObservation = ValueObservation>>> - public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId) - - private func setupObservableThreadData(for threadId: String) -> ThreadObservation { - return ObservationBuilderOld - .databaseObservation(dependencies) { [weak self, dependencies] db -> SessionThreadViewModel? in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true) - let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel - .conversationQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) - let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? - nil : - try Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == threadViewModel?.openGroupServer?.lowercased()) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - ) + + /// Pull data from libSession + if threadNeedsRefresh { + let result: (wasKickedFromGroup: Bool, groupIsDestroyed: Bool) = { + guard threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) + } + }() + threadWasKickedFromGroup = result.wasKickedFromGroup + threadGroupIsDestroyed = result.groupIsDestroyed + } + + /// Then handle database events + if !dependencies[singleton: .storage].isSuspended, (threadNeedsRefresh || !databaseEvents.isEmpty) { + do { + var fetchedInteractions: [Interaction] = [] + var fetchedProfiles: [Profile] = [] + var fetchedLinkPreviews: [LinkPreview] = [] + var fetchedAttachments: [Attachment] = [] + var fetchedInteractionAttachments: [InteractionAttachment] = [] + var fetchedReactions: [Int64: [Reaction]] = [:] + var fetchedQuoteMap: [Int64: Int64] = [:] + + /// Identify any inserted/deleted records + var insertedInteractionIds: Set = [] + var updatedInteractionIds: Set = [] + var deletedInteractionIds: Set = [] + var updatedAttachmentIds: Set = [] + var interactionIdsNeedingReactionUpdates: Set = [] - return threadViewModel.map { viewModel -> SessionThreadViewModel in - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard viewModel.threadVariant == .group else { return (false, false) } + databaseEvents.forEach { event in + switch event.value { + case let messageEvent as MessageEvent: + guard let messageId: Int64 = messageEvent.id else { return } + + switch event.key.generic { + case .messageCreated: insertedInteractionIds.insert(messageId) + case .messageUpdated: updatedInteractionIds.insert(messageId) + case .messageDeleted: deletedInteractionIds.insert(messageId) + default: break + } + + case let conversationEvent as ConversationEvent: + switch conversationEvent.change { + /// Since we cache whether a messages disappearing message config can be followed we + /// need to update the value if the disappearing message config on the conversation changes + case .disappearingMessageConfiguration: + itemCache.forEach { id, item in + guard item.canFollowDisappearingMessagesSetting else { return } + + updatedInteractionIds.insert(id) + } + + default: break + } + + case let attachmentEvent as AttachmentEvent: + switch event.key.generic { + case .attachmentUpdated: updatedAttachmentIds.insert(attachmentEvent.id) + default: break + } + + case let reactionEvent as ReactionEvent: + interactionIdsNeedingReactionUpdates.insert(reactionEvent.messageId) - let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) + case let communityEvent as CommunityEvent: + switch communityEvent.change { + case .receivedInitialMessages: break /// This is custom handled above + case .role: + updatedInteractionIds.insert( + contentsOf: Set(itemCache + .filter { currentUserSessionIds.contains($0.value.authorId) } + .keys) + ) + + case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): + let modAdminIds: Set = Set(admins + hiddenAdmins + moderators + hiddenModerators) + updatedInteractionIds.insert( + contentsOf: Set(itemCache + .filter { + guard modAdminIds.contains($0.value.authorId) else { + return $0.value.isSenderModeratorOrAdmin + } + + return !$0.value.isSenderModeratorOrAdmin + } + .keys) + ) + + case .capabilities, .permissions: break /// Shouldn't affect messages + } + + default: break + } + } + + try await dependencies[singleton: .storage].readAsync { db in + var interactionIdsNeedingFetch: [Int64] = Array(updatedInteractionIds) + var attachmentIdsNeedingFetch: [String] = Array(updatedAttachmentIds) + + /// Separately fetch the `initialUnreadInteractionInfo` if needed + if shouldFetchInitialUnreadInteractionInfo { + initialUnreadInteractionInfo = try Interaction + .select(.id, .timestampMs) + .filter(Interaction.Columns.wasRead == false) + .filter(Interaction.Columns.threadId == threadId) + .order(Interaction.Columns.timestampMs.asc) + .asRequest(of: Interaction.TimestampInfo.self) + .fetchOne(db) + } + + /// If we don't have the `Contact` data and need it then fetch it now + if threadVariant == .contact && threadContact?.id != threadId { + threadContact = try Contact.fetchOne(db, id: threadId) + } + + /// Update loaded page info as needed + if loadPageEvent != nil || !insertedInteractionIds.isEmpty || !deletedInteractionIds.isEmpty { + let target: PagedData.Target + + switch loadPageEvent?.target { + case .initial: + /// If we don't have an initial `focusedInteractionInfo` then we should default to loading + /// data around the `initialUnreadInteractionInfo` and focus on that + let finalLoadPageEvent: LoadPageEvent = ( + initialUnreadInteractionInfo.map { .initialPageAround(id: $0.id) } ?? + .initial + ) + + focusedInteractionInfo = initialUnreadInteractionInfo + target = ( + finalLoadPageEvent.target(with: loadResult) ?? + .newItems(insertedIds: insertedInteractionIds, deletedIds: deletedInteractionIds) + ) + + default: + target = ( + loadPageEvent?.target(with: loadResult) ?? + .newItems(insertedIds: insertedInteractionIds, deletedIds: deletedInteractionIds) + ) + } + + loadResult = try loadResult.load( + db, + target: target + ) + interactionIdsNeedingFetch += loadResult.newIds + } + + /// Get the ids of any quoted interactions + let quoteInteractionIdResults: Set> = try MessageViewModel + .quotedInteractionIds( + for: interactionIdsNeedingFetch, + currentUserSessionIds: currentUserSessionIds + ) + .fetchSet(db) + quoteInteractionIdResults.forEach { pair in + fetchedQuoteMap[pair.first] = pair.second + } + interactionIdsNeedingFetch += Array(fetchedQuoteMap.values) + + /// Fetch any records needed + fetchedInteractions = try Interaction.fetchAll(db, ids: interactionIdsNeedingFetch) + + /// Determine if we need to fetch any profile data + let profileIdsForFetchedInteractions: Set = fetchedInteractions.reduce(into: []) { result, next in + result.insert(next.authorId) + result.insert(contentsOf: MentionUtilities.allPubkeys(in: (next.body ?? ""))) + } + let missingProfileIds: Set = profileIdsForFetchedInteractions + .subtracting(profileCache.keys) + + if !missingProfileIds.isEmpty { + fetchedProfiles = try Profile.fetchAll(db, ids: Array(missingProfileIds)) + } + + /// Fetch any link previews needed + let linkPreviewLookupInfo: [(url: String, timestamp: Int64)] = fetchedInteractions.compactMap { + guard let url: String = $0.linkPreviewUrl else { return nil } + + return (url, $0.timestampMs) + } + + if !linkPreviewLookupInfo.isEmpty { + let urls: [String] = linkPreviewLookupInfo.map(\.url) + let minTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).min() ?? 0) + let maxTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).max() ?? Int64.max) + let finalMinTimestamp: TimeInterval = (TimeInterval(minTimestampMs / 1000) - LinkPreview.timstampResolution) + let finalMaxTimestamp: TimeInterval = (TimeInterval(maxTimestampMs / 1000) + LinkPreview.timstampResolution) + + fetchedLinkPreviews = try LinkPreview + .filter(urls.contains(LinkPreview.Columns.url)) + .filter(LinkPreview.Columns.timestamp > finalMinTimestamp) + .filter(LinkPreview.Columns.timestamp < finalMaxTimestamp) + .fetchAll(db) + attachmentIdsNeedingFetch += fetchedLinkPreviews.compactMap { $0.attachmentId } + } + + /// Fetch any attachments needed (ensuring we keep the album order) + fetchedInteractionAttachments = try InteractionAttachment + .filter(interactionIdsNeedingFetch.contains(InteractionAttachment.Columns.interactionId)) + .order(InteractionAttachment.Columns.albumIndex) + .fetchAll(db) + attachmentIdsNeedingFetch += fetchedInteractionAttachments.map { $0.attachmentId } + + if !attachmentIdsNeedingFetch.isEmpty { + fetchedAttachments = try Attachment.fetchAll(db, ids: attachmentIdsNeedingFetch) + } + + /// Fetch any reactions (just refetch all of them as handling individual reaction events, especially with "pending" + /// reactions in SOGS, will likely result in bugs) + interactionIdsNeedingReactionUpdates.insert(contentsOf: Set(interactionIdsNeedingFetch)) + fetchedReactions = try Reaction + .filter(interactionIdsNeedingReactionUpdates.contains(Reaction.Columns.interactionId)) + .fetchAll(db) + .grouped(by: \.interactionId) + + /// Fetch any thread data needed + if threadNeedsRefresh { + threadViewModel = try ConversationViewModel.fetchThreadViewModel( + db, + threadId: threadId, + userSessionId: previousState.userSessionId, + currentUserSessionIds: currentUserSessionIds, + threadWasKickedFromGroup: threadWasKickedFromGroup, + threadGroupIsDestroyed: threadGroupIsDestroyed, + using: dependencies + ) + } + } + + threadIsTrusted = { + switch threadVariant { + case .legacyGroup, .community, .group: return true /// Default to `true` for non-contact threads + case .contact: return (threadContact?.isTrusted == true) + } + }() + + /// Update the caches with the newly fetched values + quoteMap.merge(fetchedQuoteMap, uniquingKeysWith: { _, new in new }) + fetchedProfiles.forEach { profileCache[$0.id] = $0 } + fetchedLinkPreviews.forEach { linkPreviewCache[$0.url, default: []].append($0) } + fetchedAttachments.forEach { attachmentCache[$0.id] = $0 } + fetchedReactions.forEach { interactionId, reactions in + guard !reactions.isEmpty else { + reactionCache.removeValue(forKey: interactionId) + return + } + + reactionCache[interactionId, default: []] = reactions + } + let groupedInteractionAttachments: [Int64: Set] = fetchedInteractionAttachments + .grouped(by: \.interactionId) + .mapValues { Set($0) } + fetchedInteractions.forEach { interaction in + guard let id: Int64 = interaction.id else { return } + + interactionCache[id] = interaction + + if + let attachments: Set = groupedInteractionAttachments[id], + !attachments.isEmpty + { + attachmentMap[id] = attachments + } + else { + attachmentMap.removeValue(forKey: id) + } + } + + /// Remove any deleted values + deletedInteractionIds.forEach { id in + itemCache.removeValue(forKey: id) + interactionCache.removeValue(forKey: id) + reactionCache.removeValue(forKey: id) + quoteMap.removeValue(forKey: id) + + attachmentMap[id]?.forEach { attachmentCache.removeValue(forKey: $0.attachmentId) } + attachmentMap.removeValue(forKey: id) + } + } catch { + let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.conversation, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } + } + else if !databaseEvents.isEmpty { + Log.warn(.conversation, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") + } + + /// If we refreshed the thread data then reaction support may have changed, so update it + if threadNeedsRefresh { + switch threadVariant { + case .legacyGroup: reactionsSupported = false + case .contact, .group: + reactionsSupported = (threadViewModel.threadIsMessageRequest != true) + + case .community: + reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport( + capability: .reactions, + on: threadViewModel.openGroupServer + ) + } + } + + /// Update the typing indicator state if needed + groupedOtherEvents?[.typingIndicator]?.forEach { event in + guard let eventValue: TypingIndicatorEvent = event.value as? TypingIndicatorEvent else { return } + + shouldShowTypingIndicator = (eventValue.change == .started) + } + + /// Handle optimistic messages + groupedOtherEvents?[.updateScreen]?.forEach { event in + guard let eventValue: ConversationViewModelEvent = event.value as? ConversationViewModelEvent else { + return + } + + switch eventValue { + case .sendMessage(let data): + optimisticallyInsertedMessages[data.temporaryId] = data + + if let attachments: [Attachment] = data.attachmentData { + attachments.forEach { attachmentCache[$0.id] = $0 } + attachmentMap[data.temporaryId] = Set(attachments.enumerated().map { index, attachment in + InteractionAttachment( + albumIndex: index, + interactionId: data.temporaryId, + attachmentId: attachment.id + ) + }) + } + + if let draft: LinkPreviewDraft = data.linkPreviewDraft { + linkPreviewCache[draft.urlString, default: []].append( + LinkPreview( + url: draft.urlString, + title: draft.title, + attachmentId: nil, /// Can't save to db optimistically + using: dependencies ) - } - }() + ) + } + + case .failedToStoreMessage(let temporaryId): + guard let data: OptimisticMessageData = optimisticallyInsertedMessages[temporaryId] else { + break + } - return viewModel.populatingPostQueryData( - recentReactionEmoji: recentReactionEmoji, - openGroupCapabilities: openGroupCapabilities, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] + optimisticallyInsertedMessages[temporaryId] = OptimisticMessageData( + temporaryId: temporaryId, + interaction: data.interaction.with( + state: .failed, + mostRecentFailureText: "shareExtensionDatabaseError".localized() ), - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) + attachmentData: data.attachmentData, + linkPreviewDraft: data.linkPreviewDraft, + linkPreviewPreparedAttachment: data.linkPreviewPreparedAttachment, + quoteModel: data.quoteModel ) - } - } - .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) - } - - public func updateThreadData(_ updatedData: SessionThreadViewModel) { - self.internalThreadData = updatedData - } - - // MARK: - Interaction Data - - private var lastInteractionIdMarkedAsRead: Int64? = nil - private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0 - public private(set) var unobservedInteractionDataChanges: [SectionModel]? - public private(set) var interactionData: [SectionModel] = [] - public private(set) var reactionExpandedInteractionIds: Set = [] - public private(set) var messageExpandedInteractionIds: Set = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? - - public var onInteractionChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? { - didSet { - // When starting to observe interaction changes we want to trigger a UI update just in case the - // data was changed while we weren't observing - if let changes: [SectionModel] = self.unobservedInteractionDataChanges { - PagedData.processAndTriggerUpdates( - updatedData: changes, - currentDataRetriever: { [weak self] in self?.interactionData }, - onDataChangeRetriever: { [weak self] in self?.onInteractionChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedInteractionDataChanges = updatedData + + case .resolveOptimisticMessage(let temporaryId, let databaseId): + guard interactionCache[databaseId] != nil else { + Log.warn(.conversation, "Attempted to resolve an optimistic message but it was missing from the cache") + return } - ) - self.unobservedInteractionDataChanges = nil + + optimisticallyInsertedMessages.removeValue(forKey: temporaryId) + attachmentMap.removeValue(forKey: temporaryId) + itemCache.removeValue(forKey: temporaryId) } } - } - - public func emptyStateText(for threadData: SessionThreadViewModel) -> String { - let blocksCommunityMessageRequests: Bool = (threadData.profile?.blocksCommunityMessageRequests == true) - - switch (threadData.threadIsNoteToSelf, threadData.threadCanWrite == true, blocksCommunityMessageRequests, threadData.wasKickedFromGroup, threadData.groupIsDestroyed) { - case (true, _, _, _, _): return "noteToSelfEmpty".localized() - case (_, false, true, _, _): - return "messageRequestsTurnedOff" - .put(key: "name", value: threadData.displayName) - .localized() + + /// Generating the `MessageViewModel` requires both the "preview" and "next" messages that will appear on + /// the screen in order to be generated correctly so we need to iterate over the interactions again - additionally since + /// modifying interactions could impact this clustering behaviour (or ever other cached content), and we add messages + /// optimistically, it's simplest to just fully regenerate the entire `itemCache` and rely on diffing to prevent incorrect changes + let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( + loadedPageInfo: loadResult.info, + optimisticMessages: optimisticallyInsertedMessages, + interactionCache: interactionCache + ) + + orderedIds.enumerated().forEach { index, id in + let optimisticMessageId: Int64? + let interaction: Interaction + let reactionInfo: [MessageViewModel.ReactionInfo]? + let quotedInteraction: Interaction? - case (_, _, _, _, true): - return "groupDeletedMemberDescription" - .put(key: "group_name", value: threadData.displayName) - .localized() - - case (_, _, _, true, _): - return "groupRemovedYou" - .put(key: "group_name", value: threadData.displayName) - .localized() - - case (_, false, false, _, _): - return "conversationsEmpty" - .put(key: "conversation_name", value: threadData.displayName) - .localized() + /// Source the interaction data from the appropriate location + switch id { + case ..<0: /// If the `id` is less than `0` then it's an optimistic message + guard let data: OptimisticMessageData = optimisticallyInsertedMessages[id] else { return } + + optimisticMessageId = data.temporaryId + interaction = data.interaction + reactionInfo = nil /// Can't react to an optimistic message + quotedInteraction = data.quoteModel.map { model -> Interaction? in + guard let interactionId: Int64 = model.quotedInteractionId else { return nil } + + return quoteMap[interactionId].map { interactionCache[$0] } + } + + default: + guard let targetInteraction: Interaction = interactionCache[id] else { return } + + optimisticMessageId = nil + interaction = targetInteraction + reactionInfo = reactionCache[id].map { reactions in + reactions.map { + MessageViewModel.ReactionInfo( + reaction: $0, + profile: profileCache[$0.authorId] + ) + } + } + quotedInteraction = quoteMap[id].map { interactionCache[$0] } + } - default: - return "groupNoMessages" - .put(key: "group_name", value: threadData.displayName) - .localized() - } - } - - private func setupPagedObserver( - for threadId: String, - userSessionId: SessionId, - currentUserSessionIds: Set, - using dependencies: Dependencies - ) -> PagedDatabaseObserver { - return PagedDatabaseObserver( - pagedTable: Interaction.self, - pageSize: ConversationViewModel.pageSize, - idColumn: .id, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: Interaction.Columns - .allCases - .filter { $0 != .wasRead } + itemCache[id] = MessageViewModel( + optimisticMessageId: optimisticMessageId, + threadId: threadId, + threadVariant: threadVariant, + threadIsTrusted: threadIsTrusted, + threadDisappearingConfiguration: threadViewModel.disappearingMessagesConfiguration, + interaction: interaction, + reactionInfo: reactionInfo, + quotedInteraction: quotedInteraction, + profileCache: profileCache, + attachmentCache: attachmentCache, + linkPreviewCache: linkPreviewCache, + attachmentMap: attachmentMap, + isSenderModeratorOrAdmin: modAdminCache.contains(interaction.authorId), + currentUserSessionIds: currentUserSessionIds, + previousInteraction: State.interaction( + at: index + 1, /// Order is inverted so `previousInteraction` is the next element + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + interactionCache: interactionCache ), - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let linkPreviewAttachment: TypedTableAlias = TypedTableAlias() - - return SQL(""" - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) - ) - LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) - """ - ) - }() + nextInteraction: State.interaction( + at: index - 1, /// Order is inverted so `nextInteraction` is the previous element + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + interactionCache: interactionCache ), - PagedData.ObservedChanges( - table: Contact.self, - columns: [.isTrusted], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - - return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") - }() + isLast: ( + /// Order is inverted so we need to check the start of the list + index == 0 && + !loadResult.info.hasPrevPage ), - PagedData.ObservedChanges( - table: Profile.self, - columns: [.displayPictureUrl], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])") - }() + isLastOutgoing: ( + /// Order is inverted so we need to check the start of the list + id == orderedIds + .prefix(index + 1) /// Want to include the value for `index` in the result + .enumerated() + .compactMap { prefixIndex, _ in + State.interaction( + at: prefixIndex, + orderedIds: orderedIds, + optimisticMessages: optimisticallyInsertedMessages, + interactionCache: interactionCache + ) + } + .first(where: { currentUserSessionIds.contains($0.authorId) })? + .id ), - PagedData.ObservedChanges( - table: DisappearingMessagesConfiguration.self, - columns: [ .isEnabled, .type, .durationSeconds ], - joinToPagedType: { - let interaction: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() - - return SQL("LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(interaction[.threadId])") - }() - ) - ], - filterSQL: MessageViewModel.filterSQL(threadId: threadId), - groupSQL: MessageViewModel.groupSQL, - orderSQL: MessageViewModel.orderSQL, - dataQuery: MessageViewModel.baseQuery( - userSessionId: userSessionId, - currentUserSessionIds: currentUserSessionIds, - orderSQL: MessageViewModel.orderSQL, - groupSQL: MessageViewModel.groupSQL + using: dependencies + ) + } + + return State( + viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), + threadId: threadId, + threadVariant: threadVariant, + userSessionId: previousState.userSessionId, + currentUserSessionIds: currentUserSessionIds, + isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadId), + wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(previousState.threadId), + focusedInteractionInfo: focusedInteractionInfo, + focusBehaviour: previousState.focusBehaviour, + initialUnreadInteractionInfo: initialUnreadInteractionInfo, + loadedPageInfo: loadResult.info, + profileCache: profileCache, + linkPreviewCache: linkPreviewCache, + interactionCache: interactionCache, + attachmentCache: attachmentCache, + reactionCache: reactionCache, + quoteMap: quoteMap, + attachmentMap: attachmentMap, + modAdminCache: modAdminCache, + itemCache: itemCache, + titleViewModel: ConversationTitleViewModel( + threadViewModel: threadViewModel, + using: dependencies ), - associatedRecords: [ - AssociatedRecord( - trackedAgainst: Attachment.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery, - joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: Reaction.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Reaction.self, - columns: [.count] - ) - ], - dataQuery: MessageViewModel.ReactionInfo.baseQuery, - joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.ReactionInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: ThreadTypingIndicator.self, - observedChanges: [ - PagedData.ObservedChanges( - table: ThreadTypingIndicator.self, - events: [.insert, .delete], - columns: [] - ) - ], - dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery, - joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL, - associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure() - ), - AssociatedRecord( - trackedAgainst: Quote.self, - observedChanges: [ - PagedData.ObservedChanges( - table: Interaction.self, - columns: [.variant] - ), - PagedData.ObservedChanges( - table: Attachment.self, - columns: [.state] - ) - ], - dataQuery: MessageViewModel.QuotedInfo.baseQuery( - userSessionId: userSessionId, - currentUserSessionIds: currentUserSessionIds - ), - joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL(), - retrieveRowIdsForReferencedRowIds: MessageViewModel.QuotedInfo.createReferencedRowIdsRetriever(), - associateData: MessageViewModel.QuotedInfo.createAssociateDataClosure() - ) - ], - onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in - self?.resolveOptimisticUpdates(with: updatedData) - - PagedData.processAndTriggerUpdates( - updatedData: self?.process( - data: updatedData, - for: updatedPageInfo, - optimisticMessages: (self?.optimisticallyInsertedMessages.values) - .map { $0.map { $0.messageViewModel } }, - initialUnreadInteractionId: self?.initialUnreadInteractionId - ), - currentDataRetriever: { self?.interactionData }, - onDataChangeRetriever: { self?.onInteractionChange }, - onUnobservedDataChange: { updatedData in - self?.unobservedInteractionDataChanges = updatedData - } - ) - }, - using: dependencies + threadViewModel: threadViewModel, + threadContact: threadContact, + threadIsTrusted: threadIsTrusted, + legacyGroupsBannerIsVisible: previousState.legacyGroupsBannerIsVisible, + reactionsSupported: reactionsSupported, + isUserModeratorOrAdmin: isUserModeratorOrAdmin, + shouldShowTypingIndicator: shouldShowTypingIndicator, + optimisticallyInsertedMessages: optimisticallyInsertedMessages ) } - private func process( - data: [MessageViewModel], - for pageInfo: PagedData.PageInfo, - optimisticMessages: [MessageViewModel]?, - initialUnreadInteractionId: Int64? - ) -> [SectionModel] { - let threadData: SessionThreadViewModel = self.internalThreadData - let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) - let sortedData: [MessageViewModel] = data - .filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates - .appending(contentsOf: (optimisticMessages ?? [])) // Insert latest optimistic updates - .filter { !$0.cellType.isPostProcessed } // Remove headers and other - .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } - let threadIsTrusted: Bool = data.contains(where: { $0.threadIsTrusted }) - - // TODO: [Database Relocation] Source profile data via a separate query for efficiency - var currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - - // We load messages from newest to oldest so having a pageOffset larger than zero means - // there are newer pages to load + private static func sections(state: State, viewModel: ConversationViewModel) -> [SectionModel] { + let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( + loadedPageInfo: state.loadedPageInfo, + optimisticMessages: state.optimisticallyInsertedMessages, + interactionCache: state.interactionCache + ) + + /// Messages are fetched in decending order (so the message at index `0` is the most recent message), we then render the + /// messages in the reverse order (so the most recent appears at the bottom of the screen) so as a result the `loadOlder` + /// section is based on `hasNextPage` and vice-versa return [ - (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ? [SectionModel(section: .loadOlder)] : [] ), [ SectionModel( section: .messages, - elements: sortedData - .enumerated() - .map { index, cellViewModel -> MessageViewModel in - cellViewModel.withClusteringChanges( - prevModel: (index > 0 ? sortedData[index - 1] : nil), - nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil), - isLast: ( - // The database query sorts by timestampMs descending so the "last" - // interaction will actually have a 'pageOffset' of '0' even though - // it's the last element in the 'sortedData' array - index == (sortedData.count - 1) && - pageInfo.pageOffset == 0 - ), - isLastOutgoing: ( - cellViewModel.id == sortedData - .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } - .last? - .id - ), - currentUserSessionIds: (threadData.currentUserSessionIds ?? []), - currentUserProfile: currentUserProfile, - threadIsTrusted: threadIsTrusted, - using: dependencies - ) - } - .reduce([]) { result, message in - let updatedResult: [MessageViewModel] = result - .appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ? - nil : + elements: orderedIds + .reversed() /// Interactions are loaded from newest to oldest, but we want the newest at the bottom so reverse the result + .compactMap { state.itemCache[$0] } + .reduce(into: []) { result, next in + /// Insert the unread indicator above the first unread message + if next.id == state.initialUnreadInteractionInfo?.id { + result.append( MessageViewModel( - timestampMs: message.timestampMs, - cellType: .unreadMarker + cellType: .unreadMarker, + timestampMs: next.timestampMs ) - ) - - guard message.shouldShowDateHeader else { - return updatedResult.appending(message) + ) } - return updatedResult - .appending( + /// If we should have a date header above this message then add it + if next.shouldShowDateHeader { + result.append( MessageViewModel( - timestampMs: message.timestampMs, - cellType: .dateHeader + cellType: .dateHeader, + timestampMs: next.timestampMs ) ) - .appending(message) + } + + /// Since we've added whatever was needed before the message we can now add it to the result + result.append(next) } - .appending(typingIndicator) + .appending(!state.shouldShowTypingIndicator ? nil : + MessageViewModel.typingIndicator + ) ) ], - (!data.isEmpty && pageInfo.pageOffset > 0 ? + (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasPrevPage ? [SectionModel(section: .loadNewer)] : [] ) ].flatMap { $0 } } - public func updateInteractionData(_ updatedData: [SectionModel]) { - self.interactionData = updatedData - } - - // MARK: - Optimistic Message Handling + // MARK: - Interaction Data - public typealias OptimisticMessageData = ( - id: UUID, - messageViewModel: MessageViewModel, - interaction: Interaction, - attachmentData: [Attachment]?, - linkPreviewDraft: LinkPreviewDraft?, - linkPreviewPreparedAttachment: PreparedAttachment?, - quoteModel: QuotedReplyModel? - ) + @MainActor public private(set) var reactionExpandedInteractionIds: Set = [] + @MainActor public private(set) var messageExpandedInteractionIds: Set = [] - @ThreadSafeObject private var optimisticallyInsertedMessages: [UUID: OptimisticMessageData] = [:] - @ThreadSafeObject private var optimisticMessageAssociatedInteractionIds: [Int64: UUID] = [:] + // MARK: - Optimistic Message Handling public func optimisticallyAppendOutgoingMessage( text: String?, @@ -728,25 +1236,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold quoteModel: QuotedReplyModel? ) async -> OptimisticMessageData { // Generate the optimistic data - let optimisticMessageId: UUID = UUID() - let threadData: SessionThreadViewModel = self.internalThreadData - let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages + let currentState: State = await self.state let interaction: Interaction = Interaction( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - authorId: (threadData.currentUserSessionIds ?? []) + threadId: currentState.threadId, + threadVariant: currentState.threadVariant, + authorId: currentState.currentUserSessionIds .first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) } - .defaulting(to: threadData.currentUserSessionId), + .defaulting(to: currentState.userSessionId.hexString), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, hasMention: Interaction.isUserMentioned( - publicKeysToCheck: (threadData.currentUserSessionIds ?? []), + publicKeysToCheck: currentState.currentUserSessionIds, body: text ), - expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), + expiresInSeconds: currentState.threadViewModel.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewDraft?.urlString, - isProMessage: (text.defaulting(to: "").utf16.count > LibSession.CharacterLimit), + isProMessage: (text.defaulting(to: "").utf16.count > SessionPro.CharacterLimit),//dependencies[cache: .libSession].isSessionPro, // TODO: [PRO] Ditch this? using: dependencies ) var optimisticAttachments: [Attachment]? @@ -767,347 +1274,294 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - // Generate the actual 'MessageViewModel' - let messageViewModel: MessageViewModel = MessageViewModel( - optimisticMessageId: optimisticMessageId, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - threadExpirationType: threadData.disappearingMessagesConfiguration?.type, - threadExpirationTimer: threadData.disappearingMessagesConfiguration?.durationSeconds, - threadOpenGroupServer: threadData.openGroupServer, - threadOpenGroupPublicKey: threadData.openGroupPublicKey, - threadContactNameInternal: threadData.threadContactName(), - timestampMs: interaction.timestampMs, - receivedAtTimestampMs: interaction.receivedAtTimestampMs, - authorId: interaction.authorId, - authorNameInternal: currentUserProfile.displayName(), - body: interaction.body, - expiresStartedAtMs: interaction.expiresStartedAtMs, - expiresInSeconds: interaction.expiresInSeconds, - isProMessage: interaction.isProMessage, - isSenderModeratorOrAdmin: { - switch threadData.threadVariant { - case .group, .legacyGroup: - return (threadData.currentUserIsClosedGroupAdmin == true) - - case .community: - return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - publicKey: threadData.currentUserSessionId, - for: threadData.openGroupRoomToken, - on: threadData.openGroupServer, - currentUserSessionIds: (threadData.currentUserSessionIds ?? []) - ) - - default: return false - } - }(), - currentUserProfile: currentUserProfile, - quotedInfo: MessageViewModel.QuotedInfo(replyModel: quoteModel), - linkPreview: linkPreviewDraft.map { draft in - LinkPreview( - url: draft.urlString, - title: draft.title, - attachmentId: nil, // Can't save to db optimistically - using: dependencies - ) - }, - linkPreviewAttachment: linkPreviewPreparedAttachment?.attachment, - attachments: optimisticAttachments - ) - let optimisticData: OptimisticMessageData = ( - optimisticMessageId, - messageViewModel, - interaction, - optimisticAttachments, - linkPreviewDraft, - linkPreviewPreparedAttachment, - quoteModel + let optimisticData: OptimisticMessageData = OptimisticMessageData( + temporaryId: optimisticMessageId, + interaction: interaction, + attachmentData: optimisticAttachments, + linkPreviewDraft: linkPreviewDraft, + linkPreviewPreparedAttachment: linkPreviewPreparedAttachment, + quoteModel: quoteModel ) - _optimisticallyInsertedMessages.performUpdate { $0.setting(optimisticMessageId, optimisticData) } - forceUpdateDataIfPossible() + await dependencies.notify( + key: .updateScreen(ConversationViewModel.self), + value: ConversationViewModelEvent.sendMessage(data: optimisticData) + ) return optimisticData } - public func failedToStoreOptimisticOutgoingMessage(id: UUID, error: Error) { - _optimisticallyInsertedMessages.performUpdate { - $0.setting( - id, - $0[id].map { - ( - $0.id, - $0.messageViewModel.with( - state: .set(to: .failed), - mostRecentFailureText: .set(to: "shareExtensionDatabaseError".localized()) - ), - $0.interaction, - $0.attachmentData, - $0.linkPreviewDraft, - $0.linkPreviewPreparedAttachment, - $0.quoteModel - ) - } - ) - } - - forceUpdateDataIfPossible() + public func failedToStoreOptimisticOutgoingMessage(id: Int64, error: Error) async { + await dependencies.notify( + key: .updateScreen(ConversationViewModel.self), + value: ConversationViewModelEvent.failedToStoreMessage(temporaryId: id) + ) } /// Record an association between an `optimisticMessageId` and a specific `interactionId` - public func associate(optimisticMessageId: UUID, to interactionId: Int64?) { + public func associate(_ db: ObservingDatabase, optimisticMessageId: Int64, to interactionId: Int64?) { guard let interactionId: Int64 = interactionId else { return } - _optimisticMessageAssociatedInteractionIds.performUpdate { - $0.setting(interactionId, optimisticMessageId) - } + db.addEvent( + ConversationViewModelEvent.resolveOptimisticMessage( + temporaryId: optimisticMessageId, + databaseId: interactionId + ), + forKey: .updateScreen(ConversationViewModel.self) + ) } - public func optimisticMessageData(for optimisticMessageId: UUID) -> OptimisticMessageData? { - return optimisticallyInsertedMessages[optimisticMessageId] - } + // MARK: - Mentions - /// Remove any optimisticUpdate entries which have an associated interactionId in the provided data - private func resolveOptimisticUpdates(with data: [MessageViewModel]) { - let interactionIds: [Int64] = data.map { $0.id } - let idsToRemove: [UUID] = _optimisticMessageAssociatedInteractionIds - .performUpdateAndMap { associatedIds in - var updatedAssociatedIds: [Int64: UUID] = associatedIds - let result: [UUID] = interactionIds.compactMap { updatedAssociatedIds.removeValue(forKey: $0) } - return (updatedAssociatedIds, result) - } - _optimisticallyInsertedMessages.performUpdate { $0.removingValues(forKeys: idsToRemove) } + public func mentions(for query: String = "") async -> [MentionInfo] { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let threadData: SessionThreadViewModel = await self.state.threadViewModel + + return ((try? await dependencies[singleton: .storage].readAsync { db -> [MentionInfo] in + let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) + let capabilities: Set = (threadData.threadVariant != .community ? + nil : + try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == threadData.openGroupServer) + .asRequest(of: Capability.Variant.self) + .fetchSet(db) + ) + .defaulting(to: []) + let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? + [.blinded15, .blinded25] : + [.standard] + ) + + return (try? MentionInfo + .query( + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + targetPrefixes: targetPrefixes, + currentUserSessionIds: ( + threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), + pattern: pattern + )? + .fetchAll(db)) + .defaulting(to: []) + }) ?? []) } + + // MARK: - Functions - private func forceUpdateDataIfPossible() { - // Ensure this is on the main thread as we access properties that could be accessed on other threads - guard Thread.isMainThread else { - return DispatchQueue.main.async { [weak self] in self?.forceUpdateDataIfPossible() } - } - - // If we can't get the current page data then don't bother trying to update (it's not going to work) - guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo else { return } - - /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above - let currentData: [SectionModel] = (unobservedInteractionDataChanges ?? interactionData) - - PagedData.processAndTriggerUpdates( - updatedData: process( - data: (currentData.first(where: { $0.model == .messages })?.elements ?? []), - for: currentPageInfo, - optimisticMessages: optimisticallyInsertedMessages.values.map { $0.messageViewModel }, - initialUnreadInteractionId: initialUnreadInteractionId - ), - currentDataRetriever: { [weak self] in self?.interactionData }, - onDataChangeRetriever: { [weak self] in self?.onInteractionChange }, - onUnobservedDataChange: { [weak self] updatedData in - self?.unobservedInteractionDataChanges = updatedData - } + @MainActor func loadPageBefore() { + /// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading + /// the "page before" we _actually_ need to load the `nextPage` + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex) ) } - // MARK: - Mentions - - public func mentions(for query: String = "") -> [MentionInfo] { - let threadData: SessionThreadViewModel = self.internalThreadData - - return dependencies[singleton: .storage] - .read { [weak self, dependencies] db -> [MentionInfo] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadData.threadVariant != .community ? - nil : - try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == threadData.openGroupServer) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - ) - .defaulting(to: []) - let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? - [.blinded15, .blinded25] : - [.standard] - ) - - return (try MentionInfo - .query( - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - targetPrefixes: targetPrefixes, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - pattern: pattern - )? - .fetchAll(db)) - .defaulting(to: []) - } - .defaulting(to: []) + @MainActor public func loadPageAfter() { + /// We render the messages in the reverse order from the way we fetch them (see `sections`) so as a result when loading + /// the "page after" we _actually_ need to load the `previousPage` + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.previousPage(firstIndex: state.loadedPageInfo.firstIndex) + ) } - // MARK: - Functions + @MainActor public func jumpToPage(for id: Int64, padding: Int) { + dependencies.notifyAsync( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.jumpTo(id: id, padding: padding) + ) + } - public func updateDraft(to draft: String) { + @MainActor public func updateDraft(to draft: String) { /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this, /// worst case the `draft` just won't be saved) - dependencies[singleton: .storage] - .readPublisher { [threadId] db in + Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in + let existingDraft: String? = try? await dependencies[singleton: .storage].readAsync { db in try SessionThread .select(.messageDraft) .filter(id: threadId) .asRequest(of: String.self) .fetchOne(db) } - .filter { existingDraft -> Bool in draft != existingDraft } - .flatMapStorageWritePublisher(using: dependencies) { [threadId] db, _ in + + guard draft != existingDraft else { return } + + _ = try? await dependencies[singleton: .storage].writeAsync { db in try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) } - .sinkUntilComplete() + } } - /// This method indicates whether the client should try to mark the thread or it's messages as read (it's an optimisation for fully read - /// conversations so we can avoid iterating through the visible conversation cells every scroll) - public func shouldTryMarkAsRead() -> Bool { - return ( - (threadData.threadUnreadCount ?? 0) > 0 || - threadData.threadWasMarkedUnread == true - ) + public func markThreadAsRead() async { + let threadViewModel: SessionThreadViewModel = await state.threadViewModel + try? await threadViewModel.markAsRead(target: .thread, using: dependencies) } /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read - public func markAsRead( - target: SessionThreadViewModel.ReadTarget, - timestampMs: Int64? - ) { + public func markAsReadIfNeeded( + interactionInfo: Interaction.TimestampInfo?, + visibleViewModelRetriever: ((@MainActor () -> [MessageViewModel]?))? + ) async { /// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database /// write queue when it isn't needed, in order to do this we: + /// - Only retrieve the visible message view models if the state suggests there is something that can be marked as read /// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list) /// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent /// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue /// /// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read - if markAsReadPublisher == nil { - markAsReadPublisher = markAsReadTrigger - .throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) - .handleEvents( - receiveOutput: { [weak self, dependencies] target, timestampMs in - let threadData: SessionThreadViewModel? = self?.internalThreadData - - switch target { - case .thread: threadData?.markAsRead(target: target, using: dependencies) - case .threadAndInteractions(let interactionId): - guard - timestampMs == nil || - (self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) || - (self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0) - else { - threadData?.markAsRead(target: .thread, using: dependencies) - return - } - - // If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead' - // to avoid needless updates - if let timestampMs: Int64 = timestampMs { - self?.lastInteractionTimestampMsMarkedAsRead = timestampMs - } - - self?.lastInteractionIdMarkedAsRead = (interactionId ?? threadData?.interactionId) - threadData?.markAsRead(target: target, using: dependencies) - } - } + let needsToMarkAsRead: Bool = await MainActor.run { + guard + (state.threadViewModel.threadUnreadCount ?? 0) > 0 || + state.threadViewModel.threadWasMarkedUnread == true + else { return false } + + /// We want to mark messages as read while we scroll, so grab the "newest" visible message and mark everything older as read + let targetInfo: Interaction.TimestampInfo + + if let newestCellViewModel: MessageViewModel = visibleViewModelRetriever?()?.last { + targetInfo = Interaction.TimestampInfo( + id: newestCellViewModel.id, + timestampMs: newestCellViewModel.timestampMs + ) + } + else if let interactionInfo: Interaction.TimestampInfo = interactionInfo { + /// If we weren't able to get any visible cells for some reason then we should fall back to marking the provided + /// `interactionInfo` as read just in case + targetInfo = interactionInfo + } + else { + /// If we can't get any interaction info then there is nothing to mark as read + return false + } + + /// If we previously marked something as read and it's "newer" than the target info then it should already be read so no + /// need to do anything + if + let oldValue: Interaction.TimestampInfo = lastMarkAsReadInfo, ( + targetInfo.id < oldValue.id || + targetInfo.timestampMs < oldValue.timestampMs ) - .map { _ in () } - .eraseToAnyPublisher() + { + return false + } + + /// If we already have pending info to mark as read then no need to trigger another update + if let pendingValue: Interaction.TimestampInfo = pendingMarkAsReadInfo { + /// If the target info is "newer" than the pending info then we sould update the pending info so the "newer" value ends + /// up getting marked as read + if targetInfo.id > pendingValue.id || targetInfo.timestampMs > pendingValue.timestampMs { + pendingMarkAsReadInfo = targetInfo + } + + return false + } - markAsReadPublisher?.sinkUntilComplete() + /// If we got here then we do need to mark the target info as read + pendingMarkAsReadInfo = targetInfo + return true } - markAsReadTrigger.send((target, timestampMs)) - } - - public func swapToThread(updatedThreadId: String, focussedMessageId: Int64?) { - self.threadId = updatedThreadId - self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId) - self.pagedDataObserver = self.setupPagedObserver( - for: updatedThreadId, - userSessionId: dependencies[cache: .general].sessionId, - currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + /// Only continue if we need to + guard needsToMarkAsRead else { return } + + do { try await Task.sleep(for: .milliseconds(100)) } + catch { return } + + /// Get the latest values + let (threadViewModel, pendingInfo): (SessionThreadViewModel, Interaction.TimestampInfo?) = await MainActor.run { + ( + state.threadViewModel, + pendingMarkAsReadInfo + ) + } + + guard let info: Interaction.TimestampInfo = pendingInfo else { return } + + try? await threadViewModel.markAsRead( + target: .threadAndInteractions(interactionsBeforeInclusive: info.id), using: dependencies ) - // Try load everything up to the initial visible message, fallback to just the initial page of messages - // if we don't have one - switch focussedMessageId { - case .some(let id): self.pagedDataObserver?.load(.initialPageAround(id: id)) - case .none: self.pagedDataObserver?.load(.pageBefore) + /// Clear the pending info so we can mark something else as read + await MainActor.run { + pendingMarkAsReadInfo = nil } } - public func trustContact() { - guard self.internalThreadData.threadVariant == .contact else { return } - - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in - try Contact - .filter(id: threadId) - .updateAll(db, Contact.Columns.isTrusted.set(to: true)) - db.addContactEvent(id: threadId, change: .isTrusted(true)) - - // Start downloading any pending attachments for this contact (UI will automatically be - // updated due to the database observation) - try Attachment - .stateInfo(authorId: threadId, state: .pendingDownload) - .fetchAll(db) - .forEach { attachmentDownloadInfo in - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .attachmentDownload, - threadId: threadId, - interactionId: attachmentDownloadInfo.interactionId, - details: AttachmentDownloadJob.Details( - attachmentId: attachmentDownloadInfo.attachmentId - ) - ), - canStartJob: true - ) - } + @MainActor public func trustContact() { + guard state.threadVariant == .contact else { return } + + Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in + try? await dependencies[singleton: .storage].writeAsync { db in + try Contact + .filter(id: threadId) + .updateAll(db, Contact.Columns.isTrusted.set(to: true)) + db.addContactEvent(id: threadId, change: .isTrusted(true)) + + // Start downloading any pending attachments for this contact (UI will automatically be + // updated due to the database observation) + try Attachment + .stateInfo(authorId: threadId, state: .pendingDownload) + .fetchAll(db) + .forEach { attachmentDownloadInfo in + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .attachmentDownload, + threadId: threadId, + interactionId: attachmentDownloadInfo.interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentDownloadInfo.attachmentId + ) + ), + canStartJob: true + ) + } + } } } - public func unblockContact() { - guard self.internalThreadData.threadVariant == .contact else { return } - - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in - try Contact - .filter(id: threadId) - .updateAllAndConfig( - db, - Contact.Columns.isBlocked.set(to: false), - using: dependencies - ) - db.addContactEvent(id: threadId, change: .isBlocked(false)) + @MainActor public func unblockContact() { + guard state.threadVariant == .contact else { return } + + Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in + try? await dependencies[singleton: .storage].writeAsync { db in + try Contact + .filter(id: threadId) + .updateAllAndConfig( + db, + Contact.Columns.isBlocked.set(to: false), + using: dependencies + ) + db.addContactEvent(id: threadId, change: .isBlocked(false)) + } } } - public func expandReactions(for interactionId: Int64) { + @MainActor public func expandReactions(for interactionId: Int64) { reactionExpandedInteractionIds.insert(interactionId) } - public func collapseReactions(for interactionId: Int64) { + @MainActor public func collapseReactions(for interactionId: Int64) { reactionExpandedInteractionIds.remove(interactionId) } - public func expandMessage(for interactionId: Int64) { + @MainActor public func expandMessage(for interactionId: Int64) { messageExpandedInteractionIds.insert(interactionId) } - public func deletionActions(for cellViewModels: [MessageViewModel]) -> MessageViewModel.DeletionBehaviours? { + @MainActor public func deletionActions(for cellViewModels: [MessageViewModel]) -> MessageViewModel.DeletionBehaviours? { return MessageViewModel.DeletionBehaviours.deletionActions( for: cellViewModels, - with: self.internalThreadData, + threadData: state.threadViewModel, + isUserModeratorOrAdmin: state.isUserModeratorOrAdmin, using: dependencies ) } @@ -1137,26 +1591,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } } - @ThreadSafeObject private var audioPlayer: OWSAudioPlayer? = nil - @ThreadSafe private var currentPlayingInteraction: Int64? = nil - @ThreadSafeObject private var playbackInfo: [Int64: PlaybackInfo] = [:] + @MainActor private var audioPlayer: OWSAudioPlayer? = nil + @MainActor private var currentPlayingInteraction: Int64? = nil + @MainActor private var playbackInfo: [Int64: PlaybackInfo] = [:] - public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { + @MainActor public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { // Use the existing info if it already exists (update it's callback if provided as that means // the cell was reloaded) if let currentPlaybackInfo: PlaybackInfo = playbackInfo[viewModel.id] { let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo .with(updateCallback: updateCallback) - - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } - + playbackInfo[viewModel.id] = updatedPlaybackInfo return updatedPlaybackInfo } // Validate the item is a valid audio item guard let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback, - let attachment: Attachment = viewModel.attachments?.first, + let attachment: Attachment = viewModel.attachments.first, attachment.isAudio, attachment.isValid, let path: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), @@ -1173,20 +1625,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) // Cache the info - _playbackInfo.performUpdate { $0.setting(viewModel.id, newPlaybackInfo) } + playbackInfo[viewModel.id] = newPlaybackInfo return newPlaybackInfo } - public func playOrPauseAudio(for viewModel: MessageViewModel) { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.playOrPauseAudio(for: viewModel) } - } - + @MainActor public func playOrPauseAudio(for viewModel: MessageViewModel) { guard - let attachment: Attachment = viewModel.attachments?.first, + let attachment: Attachment = viewModel.attachments.first, let filePath: String = try? dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl), dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { return } @@ -1200,23 +1646,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold playbackRate: 1 ) - _audioPlayer.perform { - $0?.playbackRate = 1 - - switch currentPlaybackInfo?.state { - case .playing: $0?.pause() - default: $0?.play() - } + audioPlayer?.playbackRate = 1 + + switch currentPlaybackInfo?.state { + case .playing: audioPlayer?.pause() + default: audioPlayer?.play() } // Update the state and then update the UI with the updated state - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } + playbackInfo[viewModel.id] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) return } // First stop any existing audio - _audioPlayer.perform { $0?.stop() } + audioPlayer?.stop() // Then setup the state for the new audio currentPlayingInteraction = viewModel.id @@ -1225,26 +1669,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer // gets deallocated it triggers state changes which cause UI bugs when auto-playing - _audioPlayer.perform { $0?.delegate = nil } - _audioPlayer.set(to: nil) + audioPlayer?.delegate = nil + audioPlayer = nil let newAudioPlayer: OWSAudioPlayer = OWSAudioPlayer( mediaUrl: URL(fileURLWithPath: filePath), audioBehavior: .audioMessagePlayback, - delegate: self + delegate: self, + using: dependencies ) newAudioPlayer.play() - newAudioPlayer.setCurrentTime(currentPlaybackTime ?? 0) - _audioPlayer.set(to: newAudioPlayer) + newAudioPlayer.currentTime = (currentPlaybackTime ?? 0) + audioPlayer = newAudioPlayer } - public func speedUpAudio(for viewModel: MessageViewModel) { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.speedUpAudio(for: viewModel) } - } - + @MainActor public func speedUpAudio(for viewModel: MessageViewModel) { // If we aren't playing the specified item then just start playing it guard viewModel.id == currentPlayingInteraction else { playOrPauseAudio(for: viewModel) @@ -1255,63 +1694,57 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .with(playbackRate: 1.5) // Speed up the audio player - _audioPlayer.perform { $0?.playbackRate = 1.5 } - - _playbackInfo.performUpdate { $0.setting(viewModel.id, updatedPlaybackInfo) } + audioPlayer?.playbackRate = 1.5 + playbackInfo[viewModel.id] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) } - public func stopAudioIfNeeded(for viewModel: MessageViewModel) { + @MainActor public func stopAudioIfNeeded(for viewModel: MessageViewModel) { guard viewModel.id == currentPlayingInteraction else { return } stopAudio() } - public func stopAudio() { - /// Ensure the `OWSAudioPlayer` logic is run on the main thread as it calls `MainAppContext.ensureSleepBlocking` - /// must run on the main thread (also there is no guarantee that `AVAudioPlayer` is thread safe so better safe than sorry) - guard Thread.isMainThread else { - return DispatchQueue.main.sync { [weak self] in self?.stopAudio() } - } - - _audioPlayer.perform { $0?.stop() } + @MainActor public func stopAudio() { + audioPlayer?.stop() currentPlayingInteraction = nil // Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer // gets deallocated it triggers state changes which cause UI bugs when auto-playing - _audioPlayer.perform { $0?.delegate = nil } - _audioPlayer.set(to: nil) + audioPlayer?.delegate = nil + audioPlayer = nil } // MARK: - OWSAudioPlayerDelegate - public func audioPlaybackState() -> AudioPlaybackState { - guard let interactionId: Int64 = currentPlayingInteraction else { return .stopped } - - return (playbackInfo[interactionId]?.state ?? .stopped) - } - - public func setAudioPlaybackState(_ state: AudioPlaybackState) { - guard let interactionId: Int64 = currentPlayingInteraction else { return } - - let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? - .with(state: state) - - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } - updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + @MainActor public var audioPlaybackState: AudioPlaybackState { + get { + guard let interactionId: Int64 = currentPlayingInteraction else { return .stopped } + + return (playbackInfo[interactionId]?.state ?? .stopped) + } + set { + guard let interactionId: Int64 = currentPlayingInteraction else { return } + + let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? + .with(state: newValue) + + playbackInfo[interactionId] = updatedPlaybackInfo + updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) + } } - public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + @MainActor public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { guard let interactionId: Int64 = currentPlayingInteraction else { return } let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? .with(progress: TimeInterval(progress)) - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) } - public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { + @MainActor public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) { guard let interactionId: Int64 = currentPlayingInteraction else { return } guard successfully else { return } @@ -1323,28 +1756,28 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) // Safe the changes and send one final update to the UI - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil) // Clear out the currently playing record stopAudio() - // If the next interaction is another voice message then autoplay it + /// If the next interaction is another voice message then autoplay it + /// + /// **Note:** Order is inverted so the next item has an earlier index guard - let messageSection: SectionModel = self.interactionData - .first(where: { $0.model == .messages }), - let currentIndex: Int = messageSection.elements - .firstIndex(where: { $0.id == interactionId }), - currentIndex < (messageSection.elements.count - 1), - messageSection.elements[currentIndex + 1].cellType == .voiceMessage, + let currentIndex: Int = state.loadedPageInfo.currentIds + .firstIndex(where: { $0 == interactionId }), + currentIndex > 0, + let nextItem: MessageViewModel = state.itemCache[state.loadedPageInfo.currentIds[currentIndex - 1]], + nextItem.cellType == .voiceMessage, dependencies.mutate(cache: .libSession, { $0.get(.shouldAutoPlayConsecutiveAudioMessages) }) else { return } - let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1] playOrPauseAudio(for: nextItem) } - public func showInvalidAudioFileAlert() { + @MainActor public func showInvalidAudioFileAlert() { guard let interactionId: Int64 = currentPlayingInteraction else { return } let updatedPlaybackInfo: PlaybackInfo? = playbackInfo[interactionId]? @@ -1355,7 +1788,100 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) stopAudio() - _playbackInfo.performUpdate { $0.setting(interactionId, updatedPlaybackInfo) } + playbackInfo[interactionId] = updatedPlaybackInfo updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData) } } + +// MARK: - Convenience + +private enum EventDataRequirement { + case databaseQuery + case other + case bothDatabaseQueryAndOther +} + +private extension ObservedEvent { + var dataRequirement: EventDataRequirement { + // FIXME: Should be able to optimise this further + switch (key, key.generic) { + case (_, .loadPage): return .databaseQuery + case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery + case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (_, .typingIndicator): return .databaseQuery + case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery + case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + case (_, .attachmentCreated), (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery + case (_, .reactionsChanged): return .databaseQuery + case (_, .communityUpdated): return .bothDatabaseQueryAndOther + case (_, .contact): return .bothDatabaseQueryAndOther + case (_, .profile): return .bothDatabaseQueryAndOther + default: return .other + } + } +} + +private extension ConversationTitleViewModel { + init(threadViewModel: SessionThreadViewModel, using dependencies: Dependencies) { + self.threadVariant = threadViewModel.threadVariant + self.displayName = threadViewModel.displayName + self.isNoteToSelf = threadViewModel.threadIsNoteToSelf + self.isMessageRequest = (threadViewModel.threadIsMessageRequest == true) + self.isSessionPro = dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadViewModel.threadMutedUntilTimestamp ?? 0)) + self.onlyNotifyForMentions = (threadViewModel.threadOnlyNotifyForMentions == true) + self.userCount = threadViewModel.userCount + self.disappearingMessagesConfig = threadViewModel.disappearingMessagesConfiguration + } +} + +// MARK: - Convenience + +public extension ConversationViewModel { + static func fetchThreadViewModel( + _ db: ObservingDatabase, + threadId: String, + userSessionId: SessionId, + currentUserSessionIds: Set, + threadWasKickedFromGroup: Bool, + threadGroupIsDestroyed: Bool, + using dependencies: Dependencies + ) throws -> SessionThreadViewModel { + let threadData: SessionThreadViewModel = try SessionThreadViewModel + .conversationQuery( + threadId: threadId, + userSessionId: userSessionId + ) + .fetchOne(db) ?? { throw StorageError.objectNotFound }() + let threadRecentReactionEmoji: [String]? = try Emoji.getRecent(db, withDefaultEmoji: true) + var threadOpenGroupCapabilities: Set? + + if threadData.threadVariant == .community { + threadOpenGroupCapabilities = try Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == threadData.openGroupServer?.lowercased()) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchSet(db) + } + + return threadData.populatingPostQueryData( + recentReactionEmoji: threadRecentReactionEmoji, + openGroupCapabilities: threadOpenGroupCapabilities, + currentUserSessionIds: currentUserSessionIds, + wasKickedFromGroup: threadWasKickedFromGroup, + groupIsDestroyed: threadGroupIsDestroyed, + threadCanWrite: threadData.determineInitialCanWriteFlag(using: dependencies), + threadCanUpload: threadData.determineInitialCanUploadFlag(using: dependencies) + ) + } +} + +private extension SessionId.Prefix { + static func isCommunityBlinded(_ id: String?) -> Bool { + switch try? SessionId.Prefix(from: id) { + case .blinded15, .blinded25: return true + case .standard, .unblinded, .group, .versionBlinded07, .none: return false + } + } +} diff --git a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift index 7a86f0e68c..bbe0fd21a0 100644 --- a/Session/Conversations/Input View/ExpandingAttachmentsButton.swift +++ b/Session/Conversations/Input View/ExpandingAttachmentsButton.swift @@ -181,7 +181,7 @@ final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate { // MARK: - Delegate protocol ExpandingAttachmentsButtonDelegate: AnyObject { - func handleDisabledAttachmentButtonTapped() + @MainActor func handleDisabledAttachmentButtonTapped() func handleGIFButtonTapped() func handleDocumentButtonTapped() diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 93260ee57a..a0f245ed79 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -348,10 +348,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M guard let quoteDraftInfo = quoteDraftInfo else { return } let hInset: CGFloat = 6 // Slight visual adjustment - let quoteView: QuoteView = QuoteView( for: .draft, - authorId: quoteDraftInfo.model.authorId, + authorName: quoteDraftInfo.model.authorName, + authorHasProBadge: quoteDraftInfo.model.proFeatures.contains(.proBadge), quotedText: quoteDraftInfo.model.body, threadVariant: threadVariant, currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index d70572f72b..bb340f3c9a 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -32,7 +32,7 @@ final class MediaPlaceholderView: UIView { let (iconName, attachmentDescription): (String, String) = { guard cellViewModel.variant == .standardIncoming, - let attachment: Attachment = cellViewModel.attachments?.first + let attachment: Attachment = cellViewModel.attachments.first else { return ( "actionsheet_document_black", // stringlint:ignore diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 0ae01b0688..7ccc8451dd 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -28,7 +28,8 @@ final class QuoteView: UIView { init( for mode: Mode, - authorId: String, + authorName: String, + authorHasProBadge: Bool, quotedText: String?, threadVariant: SessionThread.Variant, currentUserSessionIds: Set, @@ -44,7 +45,8 @@ final class QuoteView: UIView { setUpViewHierarchy( mode: mode, - authorId: authorId, + authorName: authorName, + authorHasProBadge: authorHasProBadge, quotedText: quotedText, threadVariant: threadVariant, currentUserSessionIds: currentUserSessionIds, @@ -63,7 +65,8 @@ final class QuoteView: UIView { private func setUpViewHierarchy( mode: Mode, - authorId: String, + authorName: String, + authorHasProBadge: Bool, quotedText: String?, threadVariant: SessionThread.Variant, currentUserSessionIds: Set, @@ -196,36 +199,18 @@ final class QuoteView: UIView { ) .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) - // Label stack view + /// Label stack view let authorLabel = SessionLabelWithProBadge( proBadgeSize: .mini, proBadgeThemeBackgroundColor: proBadgeThemeColor ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = { - guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } - guard body != nil else { - // When we can't find the quoted message we want to hide the author label - return Profile.displayNameNoFallback( - id: authorId, - threadVariant: threadVariant, - suppressId: true, - using: dependencies - ) - } - - return Profile.displayName( - id: authorId, - threadVariant: threadVariant, - suppressId: true, - using: dependencies - ) - }() + authorLabel.text = authorName authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.numberOfLines = 1 - authorLabel.isHidden = (authorLabel.text == nil) - authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } + authorLabel.isHidden = (body == nil) /// When we can't find the quoted message we want to hide the author label/ + authorLabel.isProBadgeHidden = !authorHasProBadge authorLabel.setCompressionResistance(.vertical, to: .required) let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift index 9f92551551..328392b6b9 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift @@ -10,7 +10,8 @@ struct QuoteView_SwiftUI: View { public enum Direction { case incoming, outgoing } public struct Info { var mode: Mode - var authorId: String + var authorName: String + var authorHasProBadge: Bool var quotedText: String? var threadVariant: SessionThread.Variant var currentUserSessionIds: Set @@ -29,7 +30,6 @@ struct QuoteView_SwiftUI: View { private var info: Info private var onCancel: (() -> ())? - private var isCurrentUser: Bool { info.currentUserSessionIds.contains(info.authorId) } private var quotedText: String? { if let quotedText = info.quotedText, !quotedText.isEmpty { return quotedText @@ -41,23 +41,6 @@ struct QuoteView_SwiftUI: View { return nil } - private var author: String? { - guard !isCurrentUser else { return "you".localized() } - guard quotedText != nil else { - // When we can't find the quoted message we want to hide the author label - return Profile.displayNameNoFallback( - id: info.authorId, - threadVariant: info.threadVariant, - using: dependencies - ) - } - - return Profile.displayName( - id: info.authorId, - threadVariant: info.threadVariant, - using: dependencies - ) - } public init(info: Info, using dependencies: Dependencies, onCancel: (() -> ())? = nil) { self.dependencies = dependencies @@ -143,14 +126,12 @@ struct QuoteView_SwiftUI: View { } }() - if let author = self.author { - Text(author) + if let quotedText = self.quotedText { + Text(info.authorName) .bold() .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: targetThemeColor) - } - - if let quotedText = self.quotedText { + AttributedText( MentionUtilities.highlightMentions( in: quotedText, @@ -212,7 +193,8 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { QuoteView_SwiftUI( info: QuoteView_SwiftUI.Info( mode: .draft, - authorId: "", + authorName: "", + authorHasProBadge: false, threadVariant: .contact, currentUserSessionIds: [], direction: .outgoing @@ -224,7 +206,8 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { QuoteView_SwiftUI( info: QuoteView_SwiftUI.Info( mode: .regular, - authorId: "", + authorName: "", + authorHasProBadge: false, threadVariant: .contact, currentUserSessionIds: [], direction: .incoming, diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index e21578ad1d..5e7c519a3a 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -124,7 +124,7 @@ final class InfoMessageCell: MessageCell { self.label.themeAttributedText = cellViewModel.body?.formatted(in: self.label) - if cellViewModel.canDoFollowingSetting() { + if cellViewModel.canFollowDisappearingMessagesSetting { self.actionLabel.isHidden = false self.actionLabel.text = "disappearingMessagesFollowSetting".localized() } @@ -182,7 +182,10 @@ final class InfoMessageCell: MessageCell { override func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } - if cellViewModel.variant == .infoDisappearingMessagesUpdate && cellViewModel.canDoFollowingSetting() { + if + cellViewModel.variant == .infoDisappearingMessagesUpdate && + cellViewModel.canFollowDisappearingMessagesSetting + { delegate?.handleItemTapped(cellViewModel, cell: self, cellLocation: gestureRecognizer.location(in: self)) } } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 5f11dded10..22ae514454 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -352,7 +352,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let profileShouldBeVisible: Bool = ( isGroupThread && cellViewModel.canHaveProfile && - cellViewModel.shouldShowProfile && + cellViewModel.shouldShowDisplayPicture && cellViewModel.profile != nil ) profilePictureView.isHidden = !cellViewModel.canHaveProfile @@ -388,11 +388,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { bubbleView.isAccessibilityElement = true // Author label - authorLabel.isHidden = (cellViewModel.senderName == nil) + authorLabel.isHidden = !cellViewModel.shouldShowAuthorName authorLabel.text = cellViewModel.authorNameSuppressedId authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) authorLabel.themeTextColor = .textPrimary - authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId) } + authorLabel.isProBadgeHidden = !cellViewModel.proFeatures.contains(.proBadge) // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity @@ -410,7 +410,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } // Reaction view - reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty != false) + reactionContainerView.isHidden = cellViewModel.reactionInfo.isEmpty populateReaction( for: cellViewModel, maxWidth: VisibleMessageCell.getMaxWidth( @@ -425,7 +425,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let (image, statusText, tintColor) = cellViewModel.state.statusIconInfo( variant: cellViewModel.variant, hasBeenReadByRecipient: cellViewModel.hasBeenReadByRecipient, - hasAttachments: (cellViewModel.attachments?.isEmpty == false) + hasAttachments: !cellViewModel.attachments.isEmpty ) messageStatusLabel.text = statusText messageStatusLabel.themeTextColor = tintColor @@ -625,10 +625,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quotedInfo.authorId, + authorName: quotedInfo.authorName, + authorHasProBadge: quotedInfo.proFeatures.contains(.proBadge), quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), + currentUserSessionIds: cellViewModel.currentUserSessionIds, direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: quotedInfo.attachment, using: dependencies @@ -717,10 +718,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quotedInfo.authorId, + authorName: quotedInfo.authorName, + authorHasProBadge: quotedInfo.proFeatures.contains(.proBadge), quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), + currentUserSessionIds: cellViewModel.currentUserSessionIds, direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: quotedInfo.attachment, using: dependencies @@ -796,10 +798,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { case (.some(let quotedInfo), _): let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quotedInfo.authorId, + authorName: quotedInfo.authorName, + authorHasProBadge: quotedInfo.proFeatures.contains(.proBadge), quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), + currentUserSessionIds: cellViewModel.currentUserSessionIds, direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: quotedInfo.attachment, using: dependencies @@ -831,9 +834,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellWidth: tableSize.width ) let albumView = MediaAlbumView( - items: (cellViewModel.attachments? - .filter { $0.isVisualMedia }) - .defaulting(to: []), + items: cellViewModel.attachments.filter { $0.isVisualMedia }, isOutgoing: cellViewModel.variant.isOutgoing, maxMessageWidth: maxMessageWidth, using: dependencies @@ -847,7 +848,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { snContentView.addArrangedSubview(albumView) case .voiceMessage: - guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + guard let attachment: Attachment = cellViewModel.attachments.first(where: { $0.isAudio }) else { return } @@ -863,7 +864,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { addViewWrappingInBubbleIfNeeded(voiceMessageView) case .audio, .genericAttachment: - guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } + guard let attachment: Attachment = cellViewModel.attachments.first else { return } // Document view let documentView = DocumentView(attachment: attachment, textColor: bodyLabelTextColor) @@ -877,13 +878,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { maxWidth: CGFloat, showExpandedReactions: Bool ) { - let reactions: OrderedDictionary = (cellViewModel.reactionInfo ?? []) + let reactions: OrderedDictionary = cellViewModel.reactionInfo .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { return } - let isSelfSend: Bool = (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) + let isSelfSend: Bool = cellViewModel.currentUserSessionIds.contains(reactionInfo.reaction.authorId) if let value: ReactionViewModel = result.value(forKey: emoji) { result.replace( @@ -943,7 +944,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { switch cellViewModel.cellType { case .voiceMessage: - guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else { + guard let attachment: Attachment = cellViewModel.attachments.first(where: { $0.isAudio }) else { return } @@ -1065,11 +1066,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) let tappedAuthorName: Bool = ( authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && - !(cellViewModel.senderName ?? "").isEmpty + !cellViewModel.authorName.isEmpty ) let tappedProfilePicture: Bool = ( profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && - cellViewModel.shouldShowProfile + cellViewModel.shouldShowDisplayPicture ) if tappedAuthorName || tappedProfilePicture { @@ -1207,9 +1208,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { private static func getFontSize(for cellViewModel: MessageViewModel) -> CGFloat { let baselineFontSize = Values.mediumFontSize - guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize } + guard cellViewModel.containsOnlyEmoji else { return baselineFontSize } - switch (cellViewModel.glyphCount ?? 0) { + switch cellViewModel.glyphCount { case 1: return baselineFontSize + 30 case 2: return baselineFontSize + 24 case 3, 4, 5: return baselineFontSize + 18 @@ -1222,10 +1223,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } private func getSize(for cellViewModel: MessageViewModel, tableSize: CGSize) -> CGSize { - guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { - preconditionFailure() - } - + let mediaAttachments: [Attachment] = cellViewModel.attachments.filter({ $0.isVisualMedia }) let maxMessageWidth = VisibleMessageCell.getMaxWidth( for: cellViewModel, cellWidth: tableSize.width @@ -1310,7 +1308,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let attributedText: ThemedAttributedString = MentionUtilities.highlightMentions( in: body, threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), + currentUserSessionIds: cellViewModel.currentUserSessionIds, location: (isOutgoing ? .outgoingMessage : .incomingMessage), textColor: textColor, attributes: [ diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index 1832fc318f..d2e82f519d 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -396,6 +396,12 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga threadVariant: threadVariant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: threadId, + type: .updated(.disappearingMessageConfiguration(updatedConfig)) + ) } } } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 9e0f56266e..d75f92b478 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -167,6 +167,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi private struct State: Equatable { let threadViewModel: SessionThreadViewModel? let disappearingMessagesConfig: DisappearingMessagesConfiguration + let conversationHasProEnabled: Bool } var title: String { @@ -190,7 +191,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi return State( threadViewModel: threadViewModel, - disappearingMessagesConfig: disappearingMessagesConfig + disappearingMessagesConfig: disappearingMessagesConfig, + conversationHasProEnabled: false // TODO: [PRO] Need to source this ) } .compactMap { [weak self] current -> [SectionModel]? in @@ -309,8 +311,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi font: .titleLarge, alignment: .center, trailingImage: { - guard !threadViewModel.threadIsNoteToSelf else { return nil } - guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + guard + current.conversationHasProEnabled && + !threadViewModel.threadIsNoteToSelf + else { return nil } return ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) }() ), @@ -332,8 +336,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Username", label: threadViewModel.displayName ), - onTapView: { [weak self, threadId, dependencies] targetView in - guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { + onTapView: { [weak self, dependencies] targetView in + guard + targetView is SessionProBadge, + !dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + else { guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal( threadViewModel: threadViewModel, @@ -350,14 +357,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .group: return .groupLimit( isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + isSessionProActivated: current.conversationHasProEnabled, proBadgeImage: SessionProBadge(size: .mini).toImage(using: dependencies) ) default: return .generic } }() - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( proCTAModalVariant, presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) @@ -1367,7 +1374,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi public static func createMemberListViewController( threadId: String, - transitionToConversation: @escaping @MainActor (String) -> Void, + transitionToConversation: @escaping @MainActor (SessionThreadViewModel?) -> Void, using dependencies: Dependencies ) -> UIViewController { return SessionTableViewController( @@ -1385,28 +1392,36 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .filter(GroupMember.Columns.groupId == threadId) .group(GroupMember.Columns.profileId), onTap: .callback { _, memberInfo in - dependencies[singleton: .storage].writeAsync( - updates: { db in - try SessionThread.upsert( - db, - id: memberInfo.profileId, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 - ), - shouldBeVisible: .useExisting, - isDraft: .useExistingOrSetTo(true) + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let maybeThreadViewModel: SessionThreadViewModel? = try? await dependencies[singleton: .storage].writeAsync { db in + try SessionThread.upsert( + db, + id: memberInfo.profileId, + variant: .contact, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo( + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 ), - using: dependencies - ) - }, - completion: { _ in - Task { @MainActor in - transitionToConversation(memberInfo.profileId) - } - } - ) + shouldBeVisible: .useExisting, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies + ) + + return try ConversationViewModel.fetchThreadViewModel( + db, + threadId: memberInfo.profileId, + userSessionId: userSessionId, + currentUserSessionIds: [userSessionId.hexString], + threadWasKickedFromGroup: false, + threadGroupIsDestroyed: false, + using: dependencies + ) + } + + await MainActor.run { + transitionToConversation(maybeThreadViewModel) + } }, using: dependencies ) @@ -1417,11 +1432,26 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.transitionToScreen( ThreadSettingsViewModel.createMemberListViewController( threadId: threadId, - transitionToConversation: { [weak self, dependencies] selectedMemberId in + transitionToConversation: { [weak self, dependencies] maybeThreadViewModel in + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("errorUnknown".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + transitionType: .present + ) + return + } + self?.transitionToScreen( ConversationVC( - threadId: selectedMemberId, - threadVariant: .contact, + threadViewModel: threadViewModel, + focusedInteractionInfo: nil, using: dependencies ), transitionType: .push diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 090b46e07a..cc80b2badc 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -5,6 +5,22 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +// MARK: - ConversationTitleViewModel + +struct ConversationTitleViewModel: Sendable, Equatable { + let threadVariant: SessionThread.Variant + let displayName: String + let isNoteToSelf: Bool + let isMessageRequest: Bool + let isSessionPro: Bool + let isMuted: Bool + let onlyNotifyForMentions: Bool + let userCount: Int? + let disappearingMessagesConfig: DisappearingMessagesConfiguration? +} + +// MARK: - ConversationTitleView + final class ConversationTitleView: UIView { private static let leftInset: CGFloat = 8 private static let leftInsetWithCallButton: CGFloat = 54 @@ -82,25 +98,6 @@ final class ConversationTitleView: UIView { // MARK: - Content - public func initialSetup( - with threadVariant: SessionThread.Variant, - isNoteToSelf: Bool, - isMessageRequest: Bool, - isSessionPro: Bool - ) { - self.update( - with: " ", - isNoteToSelf: isNoteToSelf, - isMessageRequest: isMessageRequest, - isSessionPro: isSessionPro, - threadVariant: threadVariant, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false, - userCount: (threadVariant != .contact ? 0 : nil), - disappearingMessagesConfig: nil - ) - } - override func layoutSubviews() { super.layoutSubviews() @@ -116,36 +113,26 @@ final class ConversationTitleView: UIView { self.oldSize = bounds.size } - @MainActor public func update( - with name: String, - isNoteToSelf: Bool, - isMessageRequest: Bool, - isSessionPro: Bool, - threadVariant: SessionThread.Variant, - mutedUntilTimestamp: TimeInterval?, - onlyNotifyForMentions: Bool, - userCount: Int?, - disappearingMessagesConfig: DisappearingMessagesConfiguration? - ) { + @MainActor public func update(with viewModel: ConversationTitleViewModel) { let shouldHaveSubtitle: Bool = ( - !isMessageRequest && ( - Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) || - onlyNotifyForMentions || - userCount != nil || - disappearingMessagesConfig?.isEnabled == true + !viewModel.isMessageRequest && ( + viewModel.isMuted || + viewModel.onlyNotifyForMentions || + viewModel.userCount != nil || + viewModel.disappearingMessagesConfig?.isEnabled == true ) ) - self.titleLabel.text = name - self.titleLabel.accessibilityLabel = name + self.titleLabel.text = viewModel.displayName + self.titleLabel.accessibilityLabel = viewModel.displayName self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) - self.titleLabel.isProBadgeHidden = !isSessionPro + self.titleLabel.isProBadgeHidden = !viewModel.isSessionPro self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for let shouldShowCallButton: Bool = ( - !isNoteToSelf && - threadVariant == .contact + !viewModel.isNoteToSelf && + viewModel.threadVariant == .contact ) self.stackViewLeadingConstraint.constant = (shouldShowCallButton ? ConversationTitleView.leftInsetWithCallButton : @@ -158,7 +145,7 @@ final class ConversationTitleView: UIView { var labelInfos: [SessionLabelCarouselView.LabelInfo] = [] - if Date().timeIntervalSince1970 <= (mutedUntilTimestamp ?? 0) { + if viewModel.isMuted { let notificationSettingsLabelString = ThemedAttributedString( string: FullConversationCell.mutePrefix, attributes: [ @@ -176,7 +163,7 @@ final class ConversationTitleView: UIView { ) ) } - else if onlyNotifyForMentions { + else if viewModel.onlyNotifyForMentions { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(named: "NotifyMentions.png")? .withRenderingMode(.alwaysTemplate) @@ -200,8 +187,8 @@ final class ConversationTitleView: UIView { ) } - if let userCount: Int = userCount { - switch threadVariant { + if let userCount: Int = viewModel.userCount { + switch viewModel.threadVariant { case .contact: break case .legacyGroup, .group: @@ -228,7 +215,7 @@ final class ConversationTitleView: UIView { } } - if let config = disappearingMessagesConfig, config.isEnabled == true { + if let config = viewModel.disappearingMessagesConfig, config.isEnabled == true { let imageAttachment = NSTextAttachment() imageAttachment.image = UIImage(systemName: "timer")? .withRenderingMode(.alwaysTemplate) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 9e568bb8a2..918047d8c2 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -22,7 +22,7 @@ final class ReactionListSheet: BaseVC { fileprivate let dependencies: Dependencies private let interactionId: Int64 private let onDismiss: (() -> ())? - private var messageViewModel: MessageViewModel = MessageViewModel() + private var messageViewModel: MessageViewModel? private var reactionSummaries: [ReactionSummary] = [] private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = [] private var lastSelectedReactionIndex: Int = 0 @@ -201,24 +201,26 @@ final class ReactionListSheet: BaseVC { // MARK: - Content public func handleInteractionUpdates( - _ allMessages: [MessageViewModel], + _ allMessages: [MessageViewModel?], selectedReaction: EmojiWithSkinTones? = nil, updatedReactionIndex: Int? = nil, initialLoad: Bool = false, shouldShowClearAllButton: Bool = false ) { - guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else { - return - } + guard + let cellViewModel: MessageViewModel = allMessages + .compactMap({ $0 }) + .first(where: { $0.id == self.interactionId }) + else { return } // If we have no more reactions (eg. the user removed the last one) then closed the list sheet - guard cellViewModel.reactionInfo?.isEmpty == false else { + guard !cellViewModel.reactionInfo.isEmpty else { close() return } // Generated the updated data - let updatedReactionInfo: OrderedDictionary = (cellViewModel.reactionInfo ?? []) + let updatedReactionInfo: OrderedDictionary = cellViewModel.reactionInfo .reduce(into: OrderedDictionary()) { result, reactionInfo in guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else { @@ -230,7 +232,7 @@ final class ReactionListSheet: BaseVC { return } - if (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) { + if cellViewModel.currentUserSessionIds.contains(reactionInfo.reaction.authorId) { updatedValue.insert(reactionInfo, at: 0) } else { @@ -380,7 +382,10 @@ final class ReactionListSheet: BaseVC { @objc private func clearAllTapped() { clearAll() } private func clearAll() { - guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return } + guard + let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji, + let messageViewModel: MessageViewModel = self.messageViewModel + else { return } delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue) } @@ -440,8 +445,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] let authorId: String = cellViewModel.reaction.authorId let canRemoveEmoji: Bool = ( - (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) && - self.messageViewModel.threadVariant != .legacyGroup + self.messageViewModel?.currentUserSessionIds.contains(authorId) == true && + self.messageViewModel?.threadVariant != .legacyGroup ) cell.update( with: SessionCell.Info( @@ -450,7 +455,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { leadingAccessory: .profile(id: authorId, profile: cellViewModel.profile), title: ( cellViewModel.profile?.displayName() ?? - authorId.truncated(threadVariant: self.messageViewModel.threadVariant) + authorId.truncated(threadVariant: self.messageViewModel?.threadVariant ?? .contact) ), trailingAccessory: (!canRemoveEmoji ? nil : .icon( @@ -461,7 +466,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), - isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) + isEnabled: (self.messageViewModel?.currentUserSessionIds.contains(authorId) == true) ), tableSize: tableView.bounds.size, using: dependencies @@ -482,10 +487,11 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { .first(where: { $0.isSelected })? .emoji, selectedReaction.rawValue == cellViewModel.reaction.emoji, - (self.messageViewModel.currentUserSessionIds ?? []).contains(cellViewModel.reaction.authorId) + let messageViewModel: MessageViewModel = self.messageViewModel, + messageViewModel.currentUserSessionIds.contains(cellViewModel.reaction.authorId) else { return } - delegate?.removeReact(self.messageViewModel, for: selectedReaction) + delegate?.removeReact(messageViewModel, for: selectedReaction) } } diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 4de1f19992..b98d0ccdec 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -390,29 +390,27 @@ extension GlobalSearchViewController { tableView.deselectRow(at: indexPath, animated: false) let section: SectionModel = self.searchResultSet.data[indexPath.section] + let focusedInteractionInfo: Interaction.TimestampInfo? = { + switch section.model { + case .groupedContacts: return nil + case .contactsAndGroups, .messages: + guard + let interactionId: Int64 = section.elements[indexPath.row].interactionId, + let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs + else { return nil } + + return Interaction.TimestampInfo( + id: interactionId, + timestampMs: timestampMs + ) + } + }() - switch section.model { - case .contactsAndGroups, .messages: - show( - threadId: section.elements[indexPath.row].threadId, - threadVariant: section.elements[indexPath.row].threadVariant, - focusedInteractionInfo: { - guard - let interactionId: Int64 = section.elements[indexPath.row].interactionId, - let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs - else { return nil } - - return Interaction.TimestampInfo( - id: interactionId, - timestampMs: timestampMs - ) - }() - ) - case .groupedContacts: - show( - threadId: section.elements[indexPath.row].threadId, - threadVariant: section.elements[indexPath.row].threadVariant - ) + Task.detached(priority: .userInitiated) { [weak self] in + await self?.show( + threadViewModel: section.elements[indexPath.row], + focusedInteractionInfo: focusedInteractionInfo + ) } } @@ -443,39 +441,32 @@ extension GlobalSearchViewController { } private func show( - threadId: String, - threadVariant: SessionThread.Variant, + threadViewModel: SessionThreadViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true - ) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated) - } - return - } - + ) async { // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the // contact has been hidden) - if threadVariant == .contact { - dependencies[singleton: .storage].write { [dependencies] db in + if threadViewModel.threadVariant == .contact { + _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try SessionThread.upsert( db, - id: threadId, - variant: threadVariant, + id: threadViewModel.threadId, + variant: threadViewModel.threadVariant, values: .existingOrDefault, using: dependencies ) } } - let viewController: ConversationVC = ConversationVC( - threadId: threadId, - threadVariant: threadVariant, - focusedInteractionInfo: focusedInteractionInfo, - using: dependencies - ) - self.navigationController?.pushViewController(viewController, animated: true) + await MainActor.run { + let viewController: ConversationVC = ConversationVC( + threadViewModel: threadViewModel, + focusedInteractionInfo: focusedInteractionInfo, + using: dependencies + ) + self.navigationController?.pushViewController(viewController, animated: true) + } } // MARK: - UITableViewDataSource diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 58f62a85b5..00d1c92f48 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -324,7 +324,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: self.viewModel.state.serviceNetwork, forceOffline: self.viewModel.state.forceOffline ) - setUpNavBarSessionHeading(currentUserSessionProState: viewModel.dependencies[singleton: .sessionProState]) + setUpNavBarSessionHeading(sessionProUIManager: viewModel.dependencies[singleton: .sessionProManager]) // Banner stack view view.addSubview(bannersStackView) @@ -625,7 +625,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { switch sections[section].model { - case .loadMore: self.viewModel.loadNextPage() + case .loadMore: self.viewModel.loadPageAfter() default: break } } @@ -645,8 +645,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi case .threads: let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] let viewController: ConversationVC = ConversationVC( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadViewModel: threadViewModel, focusedInteractionInfo: nil, using: viewModel.dependencies ) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 2359464703..64b8056203 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -74,6 +74,7 @@ public class HomeViewModel: NavigatableStateHolder { } deinit { + observationTask?.cancel() NotificationCenter.default.removeObserver(self) } @@ -588,8 +589,12 @@ public class HomeViewModel: NavigatableStateHolder { ) } ), - threadCanWrite: false, // Irrelevant for the HomeViewModel - threadCanUpload: false // Irrelevant for the HomeViewModel + threadCanWrite: conversation.determineInitialCanWriteFlag( + using: viewModel.dependencies + ), + threadCanUpload: conversation.determineInitialCanUploadFlag( + using: viewModel.dependencies + ) ) } ) @@ -800,7 +805,7 @@ public class HomeViewModel: NavigatableStateHolder { ) } - @MainActor public func loadNextPage() { + @MainActor public func loadPageAfter() { dependencies.notifyAsync( key: .loadPage(HomeViewModel.self), value: LoadPageEvent.nextPage(lastIndex: state.loadedPageInfo.lastIndex) diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index ddece1ddc5..cc1ea0cc96 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -319,16 +319,20 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O ) } ), - threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel - threadCanUpload: false // Irrelevant for the MessageRequestsViewModel + threadCanWrite: conversation.determineInitialCanWriteFlag( + using: viewModel.dependencies + ), + threadCanUpload: conversation.determineInitialCanUploadFlag( + using: viewModel.dependencies + ) ), accessibility: Accessibility( identifier: "Message request" ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: ConversationVC = ConversationVC( - threadId: conversation.threadId, - threadVariant: conversation.threadVariant, + threadViewModel: conversation, + focusedInteractionInfo: nil, using: dependencies ) viewModel?.transitionToScreen(viewController, transitionType: .push) diff --git a/Session/Home/New Conversation/NewMessageScreen.swift b/Session/Home/New Conversation/NewMessageScreen.swift index 4eb0dbfa9f..ff60b11549 100644 --- a/Session/Home/New Conversation/NewMessageScreen.swift +++ b/Session/Home/New Conversation/NewMessageScreen.swift @@ -119,14 +119,16 @@ struct NewMessageScreen: View { } } - private func startNewDM(with sessionId: String) { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: sessionId, - variant: .contact, - action: .compose, - dismissing: self.host.controller, - animated: false - ) + @MainActor private func startNewDM(with sessionId: String) { + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: sessionId, + variant: .contact, + action: .compose, + dismissing: self.host.controller, + animated: false + ) + } } } diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 1f2a1bb7ee..e1caec5328 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -8,6 +8,66 @@ import SessionMessagingKit import Lucide struct MessageInfoScreen: View { + public struct ViewModel { + let dependencies: Dependencies + let actions: [ContextMenuVC.Action] + let messageViewModel: MessageViewModel + let threadCanWrite: Bool + let onStartThread: (@MainActor () -> Void)? + let isMessageFailed: Bool + let isCurrentUser: Bool + let profileInfo: ProfilePictureView.Info? + let proFeatures: [ProFeature] + + func ctaVariant(currentUserIsPro: Bool) -> ProCTAModal.Variant { + guard let firstFeature: ProFeature = proFeatures.first, proFeatures.count > 1 else { + return .generic + } + + switch firstFeature { + case .proBadge: return .generic + case .increasedMessageLength: return .longerMessages + case .animatedDisplayPicture: return .animatedProfileImage(isSessionProActivated: currentUserIsPro) + } + } + } + + public enum ProFeature: Equatable { + case proBadge + case increasedMessageLength + case animatedDisplayPicture + + var title: String { + switch self { + case .proBadge: + return "appProBadge" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + + case .increasedMessageLength: return "proIncreasedMessageLengthFeature".localized() + case .animatedDisplayPicture: return "proAnimatedDisplayPictureFeature".localized() + } + } + + static func from(_ features: SessionPro.Features) -> [ProFeature] { + var result: [ProFeature] = [] + + if features.contains(.proBadge) { + result.append(.proBadge) + } + + if features.contains(.largerCharacterLimit) { + result.append(.increasedMessageLength) + } + + if features.contains(.animatedAvatar) { + result.append(.animatedDisplayPicture) + } + + return result + } + } + @EnvironmentObject var host: HostWrapper @State var index = 1 @@ -16,16 +76,7 @@ struct MessageInfoScreen: View { static private let cornerRadius: CGFloat = 17 - var actions: [ContextMenuVC.Action] - var messageViewModel: MessageViewModel - let threadCanWrite: Bool - let onStartThread: (@MainActor () -> Void)? - let dependencies: Dependencies - let isMessageFailed: Bool - let isCurrentUser: Bool - let profileInfo: ProfilePictureView.Info? - var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic + var viewModel: ViewModel public init( actions: [ContextMenuVC.Action], @@ -34,30 +85,25 @@ struct MessageInfoScreen: View { onStartThread: (@MainActor () -> Void)?, using dependencies: Dependencies ) { - self.actions = actions - self.messageViewModel = messageViewModel - self.threadCanWrite = threadCanWrite - self.onStartThread = onStartThread - self.dependencies = dependencies - - self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) - self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) - self.profileInfo = ProfilePictureView.getProfilePictureInfo( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), - using: dependencies - ).info - - (self.proFeatures, self.proCTAVariant) = getProFeaturesInfo() + self.viewModel = ViewModel( + dependencies: dependencies, + actions: actions.filter { $0.actionType != .emoji }, // Exclude emoji actions + messageViewModel: messageViewModel, + threadCanWrite: threadCanWrite, + onStartThread: onStartThread, + isMessageFailed: [.failed, .failedToSync].contains(messageViewModel.state), + isCurrentUser: messageViewModel.currentUserSessionIds.contains(messageViewModel.authorId), + profileInfo: ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: messageViewModel.profile.id, + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), + using: dependencies + ).info, + proFeatures: ProFeature.from(messageViewModel.proFeatures) + ) } var body: some View { @@ -73,9 +119,9 @@ struct MessageInfoScreen: View { ) { // Message bubble snapshot MessageBubble( - messageViewModel: messageViewModel, + messageViewModel: viewModel.messageViewModel, attachmentOnly: false, - dependencies: dependencies + dependencies: viewModel.dependencies ) .clipShape( RoundedRectangle(cornerRadius: Self.cornerRadius) @@ -83,7 +129,7 @@ struct MessageInfoScreen: View { .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( - themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + themeColor: (viewModel.messageViewModel.variant == .standardIncoming || viewModel.messageViewModel.variant == .standardIncomingDeleted || viewModel.messageViewModel.variant == .standardIncomingDeletedLocally ? .messageBubble_incomingBackground : .messageBubble_outgoingBackground) ) @@ -99,11 +145,11 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) - if isMessageFailed { - let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( - variant: messageViewModel.variant, - hasBeenReadByRecipient: messageViewModel.hasBeenReadByRecipient, - hasAttachments: (messageViewModel.attachments?.isEmpty == false) + if viewModel.isMessageFailed { + let (image, statusText, tintColor) = viewModel.messageViewModel.state.statusIconInfo( + variant: viewModel.messageViewModel.variant, + hasBeenReadByRecipient: viewModel.messageViewModel.hasBeenReadByRecipient, + hasAttachments: !viewModel.messageViewModel.attachments.isEmpty ) HStack(spacing: 6) { @@ -125,8 +171,10 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) } - if let attachments = messageViewModel.attachments { - switch messageViewModel.cellType { + if !viewModel.messageViewModel.attachments.isEmpty { + let attachments: [Attachment] = viewModel.messageViewModel.attachments + + switch viewModel.messageViewModel.cellType { case .mediaMessage: let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] @@ -135,9 +183,9 @@ struct MessageInfoScreen: View { // Attachment carousel view SessionCarouselView_SwiftUI( index: $index, - isOutgoing: (messageViewModel.variant == .standardOutgoing), + isOutgoing: (viewModel.messageViewModel.variant == .standardOutgoing), contentInfos: attachments, - using: dependencies + using: viewModel.dependencies ) .frame( maxWidth: .infinity, @@ -147,10 +195,10 @@ struct MessageInfoScreen: View { } else { MediaView_SwiftUI( attachment: attachments[0], - isOutgoing: (messageViewModel.variant == .standardOutgoing), + isOutgoing: (viewModel.messageViewModel.variant == .standardOutgoing), shouldSupressControls: true, cornerRadius: 0, - using: dependencies + using: viewModel.dependencies ) .frame( maxWidth: .infinity, @@ -183,9 +231,9 @@ struct MessageInfoScreen: View { default: MessageBubble( - messageViewModel: messageViewModel, + messageViewModel: viewModel.messageViewModel, attachmentOnly: true, - dependencies: dependencies + dependencies: viewModel.dependencies ) .clipShape( RoundedRectangle(cornerRadius: Self.cornerRadius) @@ -193,7 +241,7 @@ struct MessageInfoScreen: View { .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( - themeColor: (messageViewModel.variant == .standardIncoming || messageViewModel.variant == .standardIncomingDeleted || messageViewModel.variant == .standardIncomingDeletedLocally ? + themeColor: (viewModel.messageViewModel.variant == .standardIncoming || viewModel.messageViewModel.variant == .standardIncomingDeleted || viewModel.messageViewModel.variant == .standardIncomingDeletedLocally ? .messageBubble_incomingBackground : .messageBubble_outgoingBackground) ) @@ -211,7 +259,8 @@ struct MessageInfoScreen: View { } // Attachment Info - if let attachments = messageViewModel.attachments, !attachments.isEmpty { + if !viewModel.messageViewModel.attachments.isEmpty { + let attachments: [Attachment] = viewModel.messageViewModel.attachments let attachment: Attachment = attachments[(index - 1 + attachments.count) % attachments.count] ZStack { @@ -294,7 +343,7 @@ struct MessageInfoScreen: View { spacing: Values.mediumSpacing ) { // Pro feature message - if proFeatures.count > 0 { + if viewModel.proFeatures.count > 0 { VStack( alignment: .leading, spacing: Values.mediumSpacing @@ -321,13 +370,13 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.smallSpacing ) { - ForEach(self.proFeatures, id: \.self) { feature in + ForEach(viewModel.proFeatures, id: \.self) { feature in HStack(spacing: Values.smallSpacing) { AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17)) .font(.system(size: 17)) .foregroundColor(themeColor: .primary) - Text(feature) + Text(feature.title) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -336,8 +385,8 @@ struct MessageInfoScreen: View { } } - if isMessageFailed { - let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() + if viewModel.isMessageFailed { + let failureText: String = viewModel.messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { Text(failureText) .font(.Body.largeRegular) @@ -345,13 +394,13 @@ struct MessageInfoScreen: View { } } else { InfoBlock(title: "sent".localized()) { - Text(messageViewModel.dateForUI.fromattedForMessageInfo) + Text(viewModel.messageViewModel.dateForUI.fromattedForMessageInfo) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } InfoBlock(title: "received".localized()) { - Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) + Text(viewModel.messageViewModel.receivedDateForUI.fromattedForMessageInfo) .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -362,12 +411,12 @@ struct MessageInfoScreen: View { spacing: 10 ) { let size: ProfilePictureView.Size = .list - if let info: ProfilePictureView.Info = self.profileInfo { + if let info: ProfilePictureView.Info = viewModel.profileInfo { ProfilePictureSwiftUI( size: size, info: info, additionalInfo: nil, - dataManager: dependencies[singleton: .imageDataManager] + dataManager: viewModel.dependencies[singleton: .imageDataManager] ) .frame( width: size.viewSize, @@ -381,18 +430,18 @@ struct MessageInfoScreen: View { spacing: Values.verySmallSpacing ) { HStack(spacing: Values.verySmallSpacing) { - if isCurrentUser { + if viewModel.isCurrentUser { Text("you".localized()) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - else if !messageViewModel.authorNameSuppressedId.isEmpty { - Text(messageViewModel.authorNameSuppressedId) + else if !viewModel.messageViewModel.authorNameSuppressedId.isEmpty { + Text(viewModel.messageViewModel.authorNameSuppressedId) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { + if viewModel.proFeatures.contains(.proBadge) { SessionProBadge_SwiftUI(size: .small) .onTapGesture { showSessionProCTAIfNeeded() @@ -400,13 +449,13 @@ struct MessageInfoScreen: View { } } - Text(messageViewModel.authorId) + Text(viewModel.messageViewModel.authorId) .font(.Display.base) .foregroundColor( themeColor: { if - messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || - messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) + viewModel.messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || + viewModel.messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) { return .textSecondary } @@ -437,21 +486,21 @@ struct MessageInfoScreen: View { .padding(.horizontal, Values.largeSpacing) // Actions - if !actions.isEmpty { + if !viewModel.actions.isEmpty { ZStack { VStack( alignment: .leading, spacing: 0 ) { ForEach( - 0...(actions.count - 1), + 0...(viewModel.actions.count - 1), id: \.self ) { index in - let tintColor: ThemeValue = actions[index].themeColor + let tintColor: ThemeValue = viewModel.actions[index].themeColor Button( action: { - actions[index].work() { - switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) { + viewModel.actions[index].work() { + switch (viewModel.actions[index].shouldDismissInfoScreen, viewModel.actions[index].feedback) { case (false, _): break case (true, .some): DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { @@ -460,16 +509,19 @@ struct MessageInfoScreen: View { default: dismiss() } } - feedbackMessage = actions[index].feedback + feedbackMessage = viewModel.actions[index].feedback }, label: { HStack(spacing: Values.largeSpacing) { - Image(uiImage: actions[index].icon!.withRenderingMode(.alwaysTemplate)) - .resizable() - .scaledToFit() - .foregroundColor(themeColor: tintColor) - .frame(width: 26, height: 26) - Text(actions[index].title) + if let icon: UIImage = viewModel.actions[index].icon?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: icon) + .resizable() + .scaledToFit() + .foregroundColor(themeColor: tintColor) + .frame(width: 26, height: 26) + } + + Text(viewModel.actions[index].title) .font(.Headings.H8) .foregroundColor(themeColor: tintColor) } @@ -478,7 +530,7 @@ struct MessageInfoScreen: View { ) .frame(height: 60) - if index < (actions.count - 1) { + if index < (viewModel.actions.count - 1) { Divider() .foregroundColor(themeColor: .borderSeparator) } @@ -505,80 +557,82 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } - private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { - var proFeatures: [String] = [] - var proCTAVariant: ProCTAModal.Variant = .generic - - guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } - - if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { - proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) - } - - if ( - messageViewModel.isProMessage && - messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit || - dependencies[feature: .messageFeatureLongMessage] - ) { - proFeatures.append("proIncreasedMessageLengthFeature".localized()) - proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) - } - - if ( - ImageDataManager.isAnimatedImage(profileInfo?.source) || - dependencies[feature: .messageFeatureAnimatedAvatar] - ) { - proFeatures.append("proAnimatedDisplayPictureFeature".localized()) - proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) - } - - return (proFeatures, proCTAVariant) - } + // TODO: [PRO] Need to add the mocking back +// private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { +// var proFeatures: [String] = [] +// var proCTAVariant: ProCTAModal.Variant = .generic +// +// guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } +// +// // TODO: [PRO] Add this back +//// if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { +// proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) +//// } +// +// // TODO: [PRO] Count this properly +// if ( +// messageViewModel.isProMessage && +// messageViewModel.body.defaulting(to: "").utf16.count > SessionPro.CharacterLimit || +// dependencies[feature: .messageFeatureLongMessage] +// ) { +// proFeatures.append("proIncreasedMessageLengthFeature".localized()) +// proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) +// } +// +// if ( +// ImageDataManager.isAnimatedImage(profileInfo?.source) || +// dependencies[feature: .messageFeatureAnimatedAvatar] +// ) { +// proFeatures.append("proAnimatedDisplayPictureFeature".localized()) +// proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) +// } +// +// return (proFeatures, proCTAVariant) +// } private func showSessionProCTAIfNeeded() { - guard dependencies[feature: .sessionProEnabled] && (!dependencies[cache: .libSession].isSessionPro) else { - return - } + guard + viewModel.dependencies[feature: .sessionProEnabled] && + !viewModel.dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + else { return } + let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: proCTAVariant, - dataManager: dependencies[singleton: .imageDataManager] + variant: viewModel.ctaVariant( + currentUserIsPro: viewModel.dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + ), + dataManager: viewModel.dependencies[singleton: .imageDataManager], + sessionProUIManager: viewModel.dependencies[singleton: .sessionProManager] ) ) self.host.controller?.present(sessionProModal, animated: true) } func showUserProfileModal() { - guard threadCanWrite else { return } + guard viewModel.threadCanWrite else { return } // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return } + guard (try? SessionId.Prefix(from: viewModel.messageViewModel.authorId)) != .blinded25 else { return } guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.getProfilePictureInfo( size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), + publicKey: viewModel.messageViewModel.profile.id, threadVariant: .contact, // Always show the display picture in 'contact' mode displayPictureUrl: nil, - profile: messageViewModel.profile, + profile: viewModel.messageViewModel.profile, profileIcon: .none, - using: dependencies + using: viewModel.dependencies ).info else { return } let (sessionId, blindedId): (String?, String?) = { - guard (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15 else { - return (messageViewModel.authorId, nil) + guard (try? SessionId.Prefix(from: viewModel.messageViewModel.authorId)) == .blinded15 else { + return (viewModel.messageViewModel.authorId, nil) } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].read { db in - try? BlindedIdLookup.fetchOne(db, id: messageViewModel.authorId) + let lookup: BlindedIdLookup? = viewModel.dependencies[singleton: .storage].read { db in + try? BlindedIdLookup.fetchOne(db, id: viewModel.messageViewModel.authorId) } - return (lookup?.sessionId, messageViewModel.authorId) + return (lookup?.sessionId, viewModel.messageViewModel.authorId) }() let qrCodeImage: UIImage? = { @@ -587,24 +641,23 @@ struct MessageInfoScreen: View { }() let isMessasgeRequestsEnabled: Bool = { - guard messageViewModel.threadVariant == .community else { return true } - return messageViewModel.profile?.blocksCommunityMessageRequests != true + guard viewModel.messageViewModel.threadVariant == .community else { return true } + return viewModel.messageViewModel.profile.blocksCommunityMessageRequests != true }() let (displayName, contactDisplayName): (String?, String?) = { guard let sessionId: String = sessionId else { - return (messageViewModel.authorNameSuppressedId, nil) + return (viewModel.messageViewModel.authorNameSuppressedId, nil) } - let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) - guard !isCurrentUser else { + guard !viewModel.messageViewModel.currentUserSessionIds.contains(sessionId) else { return ("you".localized(), "you".localized()) } return ( - messageViewModel.authorName, - messageViewModel.profile?.displayName( - for: messageViewModel.threadVariant, + viewModel.messageViewModel.authorName, + viewModel.messageViewModel.profile.displayName( + for: viewModel.messageViewModel.threadVariant, ignoringNickname: true ) ) @@ -619,12 +672,13 @@ struct MessageInfoScreen: View { profileInfo: profileInfo, displayName: displayName, contactDisplayName: contactDisplayName, - isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), + // TODO: [PRO] Pretty sure this should be based on their current profile pro features (ie. if they don't have any pro features enabled then we shouldn't show their pro status) + isProUser: false,//dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), isMessageRequestsEnabled: isMessasgeRequestsEnabled, - onStartThread: self.onStartThread, + onStartThread: viewModel.onStartThread, onProBadgeTapped: self.showSessionProCTAIfNeeded ), - dataManager: dependencies[singleton: .imageDataManager] + dataManager: viewModel.dependencies[singleton: .imageDataManager] ) ) self.host.controller?.present(userProfileModal, animated: true, completion: nil) @@ -632,13 +686,13 @@ struct MessageInfoScreen: View { private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( - for: messageViewModel.threadId, - threadVariant: messageViewModel.threadVariant, - interactionId: messageViewModel.id, + for: viewModel.messageViewModel.threadId, + threadVariant: viewModel.messageViewModel.threadVariant, + interactionId: viewModel.messageViewModel.id, selectedAttachmentId: attachment.id, options: [ .sliderEnabled ], useTransitioningDelegate: false, - using: dependencies + using: viewModel.dependencies ) { self.host.controller?.present(mediaGalleryView, animated: true) } @@ -722,10 +776,11 @@ struct MessageBubble: View { QuoteView_SwiftUI( info: .init( mode: .regular, - authorId: quotedInfo.authorId, + authorName: quotedInfo.authorName, + authorHasProBadge: quotedInfo.proFeatures.contains(.proBadge), quotedText: quotedInfo.body, threadVariant: messageViewModel.threadVariant, - currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), + currentUserSessionIds: messageViewModel.currentUserSessionIds, direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), attachment: quotedInfo.attachment ), @@ -766,13 +821,13 @@ struct MessageBubble: View { else { switch messageViewModel.cellType { case .voiceMessage: - if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ + if let attachment: Attachment = messageViewModel.attachments.first(where: { $0.isAudio }){ // TODO: Playback Info and check if playing function is needed VoiceMessageView_SwiftUI(attachment: attachment) .padding(.top, Self.inset) } case .audio, .genericAttachment: - if let attachment: Attachment = messageViewModel.attachments?.first { + if let attachment: Attachment = messageViewModel.attachments.first { DocumentView_SwiftUI( maxWidth: $maxWidth, attachment: attachment, @@ -861,35 +916,43 @@ struct MessageInfoView_Previews: PreviewProvider { static var messageViewModel: MessageViewModel { let dependencies: Dependencies = .createEmpty() let result = MessageViewModel( - optimisticMessageId: UUID(), + optimisticMessageId: 0, threadId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg", threadVariant: .contact, - threadExpirationType: nil, - threadExpirationTimer: nil, - threadOpenGroupServer: nil, - threadOpenGroupPublicKey: nil, - threadContactNameInternal: "Test", - timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), - receivedAtTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), - authorId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg", - authorNameInternal: "Test", - body: "Mauris sapien dui, sagittis et fringilla eget, tincidunt vel mauris. Mauris bibendum quis ipsum ac pulvinar. Integer semper elit vitae placerat efficitur. Quisque blandit scelerisque orci, a fringilla dui. In a sollicitudin tortor. Vivamus consequat sollicitudin felis, nec pretium dolor bibendum sit amet. Integer non congue risus, id imperdiet diam. Proin elementum enim at felis commodo semper. Pellentesque magna magna, laoreet nec hendrerit in, suscipit sit amet risus. Nulla et imperdiet massa. Donec commodo felis quis arcu dignissim lobortis. Praesent nec fringilla felis, ut pharetra sapien. Donec ac dignissim nisi, non lobortis justo. Nulla congue velit nec sodales bibendum. Nullam feugiat, mauris ac consequat posuere, eros sem dignissim nulla, ac convallis dolor sem rhoncus dolor. Cras ut luctus risus, quis viverra mauris.", - expiresStartedAtMs: nil, - expiresInSeconds: nil, - isProMessage: true, - state: .failed, - isSenderModeratorOrAdmin: false, - currentUserProfile: Profile( - id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", - name: "TestUser" + threadIsTrusted: true, + threadDisappearingConfiguration: nil, + interaction: Interaction( + threadId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg", + threadVariant: .contact, + authorId: "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg", + variant: .standardIncoming, + body: "Mauris sapien dui, sagittis et fringilla eget, tincidunt vel mauris. Mauris bibendum quis ipsum ac pulvinar. Integer semper elit vitae placerat efficitur. Quisque blandit scelerisque orci, a fringilla dui. In a sollicitudin tortor. Vivamus consequat sollicitudin felis, nec pretium dolor bibendum sit amet. Integer non congue risus, id imperdiet diam. Proin elementum enim at felis commodo semper. Pellentesque magna magna, laoreet nec hendrerit in, suscipit sit amet risus. Nulla et imperdiet massa. Donec commodo felis quis arcu dignissim lobortis. Praesent nec fringilla felis, ut pharetra sapien. Donec ac dignissim nisi, non lobortis justo. Nulla congue velit nec sodales bibendum. Nullam feugiat, mauris ac consequat posuere, eros sem dignissim nulla, ac convallis dolor sem rhoncus dolor. Cras ut luctus risus, quis viverra mauris.", + timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), + state: .failed, + isProMessage: true, + using: dependencies ), - quotedInfo: nil, - linkPreview: nil, - linkPreviewAttachment: nil, - attachments: nil + reactionInfo: nil, + quotedInteraction: nil, + profileCache: [ + "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg": Profile( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + name: "TestUser" + ) + ], + attachmentCache: [:], + linkPreviewCache: [:], + attachmentMap: [:], + isSenderModeratorOrAdmin: false, + currentUserSessionIds: ["d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg"], + previousInteraction: nil, + nextInteraction: nil, + isLast: true, + isLastOutgoing: false, + using: dependencies ) - return result + return result! } static var actions: [ContextMenuVC.Action] { diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 69243f1487..9cef40a4c3 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -39,7 +39,11 @@ class PhotoCapture: NSObject { } private(set) var desiredPosition: AVCaptureDevice.Position = .back - let recordingAudioActivity = AudioActivity(audioDescription: "PhotoCapture", behavior: .playAndRecord) + lazy var recordingAudioActivity = AudioActivity( + audioDescription: "PhotoCapture", + behavior: .playAndRecord, + using: dependencies + ) init(using dependencies: Dependencies) { self.dependencies = dependencies @@ -54,7 +58,7 @@ class PhotoCapture: NSObject { func startAudioCapture() throws { assertIsOnSessionQueue() - guard SessionEnvironment.shared?.audioSession.startAudioActivity(recordingAudioActivity) == true else { + guard dependencies[singleton: .audioSession].startAudioActivity(recordingAudioActivity) else { throw PhotoCaptureError.assertionError(description: "unable to capture audio activity") } @@ -85,7 +89,7 @@ class PhotoCapture: NSObject { } session.removeInput(audioDeviceInput) self.audioDeviceInput = nil - SessionEnvironment.shared?.audioSession.endAudioActivity(recordingAudioActivity) + dependencies[singleton: .audioSession].endAudioActivity(recordingAudioActivity) } func startCapture() -> AnyPublisher { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 5e9f424e14..6875267409 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -467,9 +467,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Log.info(.cat, "Migrations completed, performing setup and ensuring rootViewController") dependencies[singleton: .jobRunner].setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) - /// We need to do a clean up for disappear after send messages that are received by push notifications before - /// the app set up the main screen and load initial data to prevent a case when the PagedDatabaseObserver - /// hasn't been setup yet then the conversation screen can show stale (ie. deleted) interactions incorrectly + /// We need to do a clean up for disappear after send messages that are received by push notifications before the app sets up + /// the main screen and loads initial data to prevent a case where the the conversation screen can show stale (ie. deleted) + /// interactions incorrectly DisappearingMessagesJob.cleanExpiredMessagesOnResume(using: dependencies) /// Now that the database is setup we can load in any messages which were processed by the extensions (flag that we will load @@ -540,7 +540,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } // May as well run these on the background thread - SessionEnvironment.shared?.audioSession.setup() + dependencies[singleton: .audioSession].setup() } private func showFailedStartupAlert( @@ -1069,7 +1069,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if let conversationVC: ConversationVC = (presentingVC as? TopBannerController)?.wrappedViewController() as? ConversationVC, - conversationVC.viewModel.threadData.threadId == call.sessionId + conversationVC.viewModel.state.threadId == call.sessionId { callVC.conversationVC = conversationVC conversationVC.resignFirstResponder() diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index d468e3d906..4a2cb0d873 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -69,43 +69,77 @@ public class SessionApp: SessionAppType { self.homeViewController = homeViewController } - @MainActor public func presentConversationCreatingIfNeeded( + public func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action = .none, dismissing presentingViewController: UIViewController?, animated: Bool - ) { + ) async { guard let homeViewController: HomeVC = self.homeViewController else { Log.error("[SessionApp] Unable to present conversation due to missing HomeVC.") return } - let threadExists: Bool? = dependencies[singleton: .storage].read { db in - SessionThread.filter(id: threadId).isNotEmpty(db) - } - /// The thread should generally exist at the time of calling this method, but on the off chance it doesn't then we need to /// `fetchOrCreate` it and should do it on a background thread just in case something is keeping the DBWrite thread /// busy as in the past this could cause the app to hang - creatingThreadIfNeededThenRunOnMain( - threadId: threadId, - variant: variant, - threadExists: (threadExists == true), - onComplete: { [weak self, dependencies] in - self?.showConversation( - threadId: threadId, - threadVariant: variant, - isMessageRequest: dependencies.mutate(cache: .libSession) { cache in - cache.isMessageRequest(threadId: threadId, threadVariant: variant) - }, - action: action, - dismissing: presentingViewController, - homeViewController: homeViewController, - animated: animated + let threadExists: Bool? = try? await dependencies[singleton: .storage].readAsync { db in + SessionThread.filter(id: threadId).isNotEmpty(db) + } + + if threadExists != true { + _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try SessionThread.upsert( + db, + id: threadId, + variant: variant, + values: SessionThread.TargetValues( + shouldBeVisible: .useLibSession, + isDraft: .useExistingOrSetTo(true) + ), + using: dependencies ) } - ) + } + + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard variant == .group else { return (false, false) } + + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)), + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + ) + } + }() + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let maybeThreadViewModel: SessionThreadViewModel? = try? await dependencies[singleton: .storage].readAsync { [dependencies] db in + try ConversationViewModel.fetchThreadViewModel( + db, + threadId: threadId, + userSessionId: userSessionId, + currentUserSessionIds: [userSessionId.hexString], + threadWasKickedFromGroup: wasKickedFromGroup, + threadGroupIsDestroyed: groupIsDestroyed, + using: dependencies + ) + } + + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + Log.error("Failed to present \(variant) conversation \(threadId) due to failure to fetch threadViewModel") + return + } + + await MainActor.run { [weak self] in + self?.showConversation( + threadViewModel: threadViewModel, + action: action, + dismissing: presentingViewController, + homeViewController: homeViewController, + animated: animated + ) + } } public func createNewConversation() { @@ -176,41 +210,8 @@ public class SessionApp: SessionAppType { // MARK: - Internal Functions - @MainActor private func creatingThreadIfNeededThenRunOnMain( - threadId: String, - variant: SessionThread.Variant, - threadExists: Bool, - onComplete: @escaping () -> Void - ) { - guard !threadExists else { - return onComplete() - } - - Task(priority: .userInitiated) { [storage = dependencies[singleton: .storage], dependencies] in - storage.writeAsync( - updates: { db in - try SessionThread.upsert( - db, - id: threadId, - variant: variant, - values: SessionThread.TargetValues( - shouldBeVisible: .useLibSession, - isDraft: .useExistingOrSetTo(true) - ), - using: dependencies - ) - }, - completion: { _ in - Task { @MainActor in onComplete() } - } - ) - } - } - @MainActor private func showConversation( - threadId: String, - threadVariant: SessionThread.Variant, - isMessageRequest: Bool, + threadViewModel: SessionThreadViewModel, action: ConversationViewModel.Action, dismissing presentingViewController: UIViewController?, homeViewController: HomeVC, @@ -221,13 +222,12 @@ public class SessionApp: SessionAppType { homeViewController.navigationController?.setViewControllers( [ homeViewController, - (isMessageRequest && action != .compose ? + (threadViewModel.threadIsMessageRequest == true && action != .compose ? SessionTableViewController(viewModel: MessageRequestsViewModel(using: dependencies)) : nil ), ConversationVC( - threadId: threadId, - threadVariant: threadVariant, + threadViewModel: threadViewModel, focusedInteractionInfo: nil, using: dependencies ) @@ -244,13 +244,13 @@ public protocol SessionAppType { func setHomeViewController(_ homeViewController: HomeVC) @MainActor func showHomeView() - @MainActor func presentConversationCreatingIfNeeded( + func presentConversationCreatingIfNeeded( for threadId: String, variant: SessionThread.Variant, action: ConversationViewModel.Action, dismissing presentingViewController: UIViewController?, animated: Bool - ) + ) async func createNewConversation() func resetData(onReset: (() -> ())) @MainActor func showPromotedScreen() diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index 74b18f89f8..0a622aacd3 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -242,13 +242,15 @@ public class NotificationActionHandler { // If this happens when the the app is not, visible we skip the animation so the thread // can be visible to the user immediately upon opening the app, rather than having to watch // it animate in from the homescreen. - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: threadId, - variant: threadVariant, - action: .none, - dismissing: dependencies[singleton: .app].homePresentedViewController, - animated: (UIApplication.shared.applicationState == .active) - ) + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: threadId, + variant: threadVariant, + action: .none, + dismissing: dependencies[singleton: .app].homePresentedViewController, + animated: (UIApplication.shared.applicationState == .active) + ) + } } @MainActor func showHomeVC() { diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index b60ed79b92..2e62c020f0 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -427,15 +427,15 @@ private extension NotificationPresenter { /// Check whether the current `frontMostViewController` is a `ConversationVC` for the conversation this notification /// would belong to then we don't want to show the notification, so retrieve the `frontMostViewController` (from the main /// thread) and check - guard - let frontMostViewController: UIViewController = DispatchQueue.main.sync(execute: { - dependencies[singleton: .appContext].frontMostViewController - }), - let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC - else { return true } + let currentOpenConversationThreadId: String? = DispatchQueue.main.sync(execute: { + (dependencies[singleton: .appContext].frontMostViewController as? ConversationVC)? + .viewModel + .state + .threadId + }) /// Show notifications for any **other** threads - return (conversationViewController.viewModel.threadData.threadId != threadId) + return (currentOpenConversationThreadId != threadId) } } diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 866b868495..22889acf69 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -198,14 +198,11 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC onError: (() -> ())? ) { Task.detached(priority: .userInitiated) { [weak self, dependencies] in - let hasExistingOpenGroup: Bool = try await dependencies[singleton: .storage].readAsync { db in - dependencies[singleton: .openGroupManager].hasExistingOpenGroup( - db, - roomToken: roomToken, - server: server, - publicKey: publicKey - ) - } + let hasExistingOpenGroup: Bool = await dependencies[singleton: .communityManager].hasExistingCommunity( + roomToken: roomToken, + server: server, + publicKey: publicKey + ) guard !hasExistingOpenGroup else { await MainActor.run { [weak self] in @@ -243,7 +240,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self, dependencies] _ in dependencies[singleton: .storage] .writePublisher { db in - dependencies[singleton: .openGroupManager].add( + dependencies[singleton: .communityManager].add( db, roomToken: roomToken, server: server, @@ -252,7 +249,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC ) } .flatMap { successfullyAddedGroup in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: roomToken, @@ -270,7 +267,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC // the next launch so remove it (the user will be left on the previous // screen so can re-trigger the join) dependencies[singleton: .storage].writeAsync { db in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: OpenGroup.idFor(roomToken: roomToken, server: server), skipLibSessionUpdate: false @@ -288,14 +285,17 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC } case .finished: - self?.presentingViewController?.dismiss(animated: true, completion: nil) + guard shouldOpenCommunity else { + self?.presentingViewController?.dismiss(animated: true, completion: nil) + return + } - if shouldOpenCommunity { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( for: OpenGroup.idFor(roomToken: roomToken, server: server), variant: .community, action: .none, - dismissing: nil, + dismissing: self?.presentingViewController, animated: false ) } diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index ddc4c72ba9..3b43fa1c04 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -13,23 +13,27 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle private let dependencies: Dependencies private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2) private var maxWidth: CGFloat - private var data: [OpenGroupManager.DefaultRoomInfo] = [] { - didSet { - // Start an observer for changes - let updatedIds: Set = data.map { $0.openGroup.id }.asSet() - - if oldValue.map({ $0.openGroup.id }).asSet() != updatedIds { - startObservingRoomChanges(for: updatedIds) - } - } - } - private var dataChangeObservable: DatabaseCancellable? { - didSet { oldValue?.cancel() } // Cancel the old observable if there was one - } + private var state: State private var heightConstraint: NSLayoutConstraint! + private var defaultRoomObservationTask: Task? + private var defaultRoomDisplayPictureObservationTask: Task? var delegate: OpenGroupSuggestionGridDelegate? + struct State: ObservableKeyProvider { + let server: String + let skipAuthentication: Bool + let data: [Network.SOGS.Room] + + var observedKeys: Set { + return Set(data.map { + let id: String = OpenGroup.idFor(roomToken: $0.token, server: server) + + return ObservableKey.conversationUpdated(id) + }) + } + } + // MARK: - UI private static let cellHeight: CGFloat = 40 @@ -115,6 +119,11 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle init(maxWidth: CGFloat, using dependencies: Dependencies) { self.dependencies = dependencies self.maxWidth = maxWidth + self.state = State( + server: Network.SOGS.defaultServer, + skipAuthentication: true, + data: [] + ) super.init(frame: CGRect.zero) @@ -129,6 +138,11 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle preconditionFailure("Use init(maxWidth:) instead.") } + deinit { + defaultRoomObservationTask?.cancel() + defaultRoomDisplayPictureObservationTask?.cancel() + } + private func initialize() { addSubview(collectionView) collectionView.pin(to: self) @@ -159,54 +173,42 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - dependencies[cache: .openGroupManager].defaultRoomsPublisher - .subscribe(on: DispatchQueue.global(qos: .default)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: self?.update() - } - }, - receiveValue: { [weak self] roomInfo in self?.data = roomInfo } - ) - } - - // MARK: - Updating - - private func startObservingRoomChanges(for openGroupIds: Set) { - // We don't actually care about the updated data as the 'update' function has the logic - // to fetch any newly downloaded images - dataChangeObservable = dependencies[singleton: .storage].start( - ValueObservation - .tracking( - regions: [ - OpenGroup.select(.name).filter(ids: openGroupIds), - OpenGroup.select(.roomDescription).filter(ids: openGroupIds), - OpenGroup.select(.displayPictureOriginalUrl).filter(ids: openGroupIds) - ], - fetch: { db in try OpenGroup.filter(ids: openGroupIds).fetchAll(db) } + defaultRoomObservationTask = Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .communityManager], dependencies] in + for await roomInfo in manager.defaultRooms { + guard let self else { return } + guard !roomInfo.rooms.isEmpty else { continue } + + /// Update the data + let updatedState: State = await State( + server: self.state.server, + skipAuthentication: self.state.skipAuthentication, + data: roomInfo.rooms ) - .removeDuplicates(), - onError: { _ in }, - onChange: { [weak self] result in - guard let strongSelf = self else { return } - let updatedGroupsByToken: [String: OpenGroup] = result - .reduce(into: [:]) { result, next in result[next.roomToken] = next } - strongSelf.data = strongSelf.data - .map { room, oldGroup in (room, (updatedGroupsByToken[room.token] ?? oldGroup)) } - strongSelf.update() + await MainActor.run { [weak self] in + self?.state = updatedState + self?.update() + + /// Observe changes to the data (no need to update the state, just refresh the UI if images were downloaded) + self?.defaultRoomDisplayPictureObservationTask?.cancel() + self?.defaultRoomDisplayPictureObservationTask = ObservationBuilder + .initialValue(updatedState) + .debounce(for: .milliseconds(250)) + .using(dependencies: dependencies) + .query({ previousState, _, _, _ -> State in previousState }) + .assign { [weak self] _ in self?.update() } + } } - ) + } } + // MARK: - Updating + private func update() { spinner.stopAnimating() spinner.isHidden = true - let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) + let roomCount: CGFloat = CGFloat(min(state.data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing)) heightConstraint.constant = height @@ -233,7 +235,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // If there isn't an even number of items then we want to calculate proper sizing return CGSize( - width: Cell.calculatedWith(for: data[indexPath.item].room.name), + width: Cell.calculatedWith(for: state.data[indexPath.item].name), height: OpenGroupSuggestionGrid.cellHeight ) } @@ -241,12 +243,17 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Data Source func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2) + return min(state.data.count, 8) // Cap to a maximum of 8 (4 rows of 2) } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath) - cell.update(with: data[indexPath.item].room, openGroup: data[indexPath.item].openGroup, using: dependencies) + cell.update( + with: state.data[indexPath.item], + server: state.server, + skipAuthentication: state.skipAuthentication, + using: dependencies + ) return cell } @@ -254,7 +261,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Interaction func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let room = data[indexPath.section * itemsPerSection + indexPath.item].room + let room = state.data[indexPath.section * itemsPerSection + indexPath.item] delegate?.join(room) collectionView.deselectItem(at: indexPath, animated: true) } @@ -355,25 +362,39 @@ extension OpenGroupSuggestionGrid { snContentView.pin(to: self) } - fileprivate func update(with room: Network.SOGS.Room, openGroup: OpenGroup, using dependencies: Dependencies) { + fileprivate func update( + with room: Network.SOGS.Room, + server: String, + skipAuthentication: Bool, + using dependencies: Dependencies + ) { label.text = room.name - let maybePath: String? = openGroup.displayPictureOriginalUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - - switch maybePath { - case .some(let path): - imageView.isHidden = false - imageView.setDataManager(dependencies[singleton: .imageDataManager]) - imageView.loadImage(from: path) - - case .none: - imageView.isHidden = true - - dependencies[singleton: .displayPictureManager].scheduleDownload( - for: .community(openGroup) + guard let imageId: String = room.imageId else { + imageView.isHidden = true + return + } + guard + let path: String = try? dependencies[singleton: .displayPictureManager].path( + for: Network.SOGS.downloadUrlString(for: imageId, server: server, roomToken: room.token) + ), + dependencies[singleton: .fileManager].fileExists(atPath: path) + else { + dependencies[singleton: .displayPictureManager].scheduleDownload( + for: .community( + imageId: imageId, + roomToken: room.token, + server: server, + skipAuthentication: skipAuthentication ) + ) + imageView.isHidden = true + return } + + imageView.isHidden = false + imageView.setDataManager(dependencies[singleton: .imageDataManager]) + imageView.loadImage(from: path) } } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index b5ec6ad200..5acd7abac5 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -197,7 +197,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], - messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar], products: [], purchasedProduct: nil, @@ -357,58 +357,57 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold to: !state.allUsersSessionPro ) } - ), - (!state.allUsersSessionPro ? nil : - SessionCell.Info( - id: .messageFeatureProBadge, - title: SessionCell.TextInfo("Message Feature: Pro Badge", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureProBadge, - oldValue: previousState.messageFeatureProBadge - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureProBadge, - to: !state.messageFeatureProBadge - ) - } - ) - ), - (!state.allUsersSessionPro ? nil : - SessionCell.Info( - id: .messageFeatureLongMessage, - title: SessionCell.TextInfo("Message Feature: Long Message", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureLongMessage, - oldValue: previousState.messageFeatureLongMessage - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureLongMessage, - to: !state.messageFeatureLongMessage - ) - } - ) - ), - (!state.allUsersSessionPro ? nil : - SessionCell.Info( - id: .messageFeatureAnimatedAvatar, - title: SessionCell.TextInfo("Message Feature: Animated Avatar", font: .subtitle), - trailingAccessory: .toggle( - state.messageFeatureAnimatedAvatar, - oldValue: previousState.messageFeatureAnimatedAvatar - ), - onTap: { [dependencies = viewModel.dependencies] in - dependencies.set( - feature: .messageFeatureAnimatedAvatar, - to: !state.messageFeatureAnimatedAvatar - ) - } - ) ) ] ) + if state.allUsersSessionPro { + features = features.appending(contentsOf: [ + SessionCell.Info( + id: .messageFeatureProBadge, + title: SessionCell.TextInfo("Message Feature: Pro Badge", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureProBadge, + oldValue: previousState.messageFeatureProBadge + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureProBadge, + to: !state.messageFeatureProBadge + ) + } + ), + SessionCell.Info( + id: .messageFeatureLongMessage, + title: SessionCell.TextInfo("Message Feature: Long Message", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureLongMessage, + oldValue: previousState.messageFeatureLongMessage + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureLongMessage, + to: !state.messageFeatureLongMessage + ) + } + ), + SessionCell.Info( + id: .messageFeatureAnimatedAvatar, + title: SessionCell.TextInfo("Message Feature: Animated Avatar", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureAnimatedAvatar, + oldValue: previousState.messageFeatureAnimatedAvatar + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureAnimatedAvatar, + to: !state.messageFeatureAnimatedAvatar + ) + } + ) + ]) + } + // MARK: - Actual Pro Transactions and APIs let purchaseStatus: String = { diff --git a/Session/Settings/NotificationSoundViewModel.swift b/Session/Settings/NotificationSoundViewModel.swift index 9b4b30b9dc..93a5ce04a6 100644 --- a/Session/Settings/NotificationSoundViewModel.swift +++ b/Session/Settings/NotificationSoundViewModel.swift @@ -33,8 +33,9 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N } deinit { - self.audioPlayer?.stop() - self.audioPlayer = nil + Task { @MainActor [audioPlayer] in + audioPlayer?.stop() + } } // MARK: - Config @@ -83,7 +84,7 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N lazy var observation: TargetObservation = ObservationBuilderOld .subject(currentSelection) - .map { [weak self] selectedSound in + .map { [weak self, dependencies] selectedSound in return [ SectionModel( model: .content, @@ -109,7 +110,8 @@ class NotificationSoundViewModel: SessionTableViewModel, NavigationItemSource, N DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { self?.audioPlayer = Preferences.Sound.audioPlayer( for: sound, - behavior: .playback + behavior: .playback, + using: dependencies ) self?.audioPlayer?.isLooping = false self?.audioPlayer?.play() diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index ffc90bcd3d..7550957fce 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -186,7 +186,6 @@ final class NukeDataModal: Modal { using: dependencies ), try OpenGroup - .filter(OpenGroup.Columns.isActive == true) .select(.server) .distinct() .asRequest(of: String.self) diff --git a/Session/Settings/QRCodeScreen.swift b/Session/Settings/QRCodeScreen.swift index 1a9666bfec..3ea8990d99 100644 --- a/Session/Settings/QRCodeScreen.swift +++ b/Session/Settings/QRCodeScreen.swift @@ -52,13 +52,15 @@ struct QRCodeScreen: View { errorString = "qrNotAccountId".localized() } else { - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: hexEncodedPublicKey, - variant: .contact, - action: .compose, - dismissing: self.host.controller, - animated: false - ) + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: hexEncodedPublicKey, + variant: .contact, + action: .compose, + dismissing: self.host.controller, + animated: false + ) + } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 1a16584a78..93d8563d20 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -181,7 +181,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl static func initialState( userSessionId: SessionId, - sessionProBackendStatus: Bool + sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? ) -> State { return State( userSessionId: userSessionId, diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 5d833e6363..8c1a06a10b 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -16,10 +16,11 @@ final class ThemeMessagePreviewView: UIView { let cell: VisibleMessageCell = VisibleMessageCell() cell.update( with: MessageViewModel( + cellType: .textOnlyMessage, + timestampMs: 0, variant: .standardIncoming, body: "appearancePreview2".localized(), - quotedInfo: MessageViewModel.QuotedInfo(previewBody: "appearancePreview1".localized()), - cellType: .textOnlyMessage + quotedInfo: MessageViewModel.QuotedInfo(previewBody: "appearancePreview1".localized()) ), playbackInfo: nil, showExpandedReactions: false, @@ -37,9 +38,10 @@ final class ThemeMessagePreviewView: UIView { let cell: VisibleMessageCell = VisibleMessageCell() cell.update( with: MessageViewModel( + cellType: .textOnlyMessage, + timestampMs: 0, variant: .standardOutgoing, body: "appearancePreview3".localized(), - cellType: .textOnlyMessage, isLast: false // To hide the status indicator ), playbackInfo: nil, diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 531395ed97..5af1f944c9 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -5,7 +5,7 @@ import SessionUIKit import Combine public class BaseVC: UIViewController { - private var disposables: Set = Set() + private var proObservationTask: Task? public var onViewWillAppear: ((UIViewController) -> Void)? public var onViewWillDisappear: ((UIViewController) -> Void)? public var onViewDidDisappear: ((UIViewController) -> Void)? @@ -33,6 +33,10 @@ public class BaseVC: UIViewController { return result }() + + deinit { + proObservationTask?.cancel() + } public override func viewDidLoad() { super.viewDidLoad() @@ -83,7 +87,7 @@ public class BaseVC: UIViewController { navigationItem.titleView = container } - internal func setUpNavBarSessionHeading(currentUserSessionProState: SessionProManagerType) { + internal func setUpNavBarSessionHeading(sessionProUIManager: SessionProUIManagerType) { let headingImageView = UIImageView( image: UIImage(named: "SessionHeading")? .withRenderingMode(.alwaysTemplate) @@ -94,7 +98,7 @@ public class BaseVC: UIViewController { headingImageView.set(.height, to: Values.mediumFontSize) let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) - sessionProBadge.isHidden = !currentUserSessionProState.isSessionProSubject.value + sessionProBadge.isHidden = !sessionProUIManager.currentUserIsCurrentlyPro let stackView: UIStackView = UIStackView( arrangedSubviews: MainAppContext.determineDeviceRTL() ? [ sessionProBadge, headingImageView ] : [ headingImageView, sessionProBadge ] @@ -103,15 +107,14 @@ public class BaseVC: UIViewController { stackView.alignment = .center stackView.spacing = 0 - currentUserSessionProState.isSessionProPublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak sessionProBadge] isPro in + proObservationTask?.cancel() + proObservationTask = Task.detached(priority: .userInitiated) { [weak sessionProBadge] in + for await isPro in sessionProUIManager.currentUserIsPro { + await MainActor.run { [weak sessionProBadge] in sessionProBadge?.isHidden = !isPro } - ) - .store(in: &disposables) + } + } navigationItem.titleView = stackView } diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index 6928938b09..e1de24bbb8 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -71,7 +71,7 @@ class UserListViewModel: SessionTableVie public indirect enum OnTapAction { case none - case callback((UserListViewModel?, WithProfile) -> Void) + case callback((UserListViewModel?, WithProfile) async -> Void) case radio case conditionalAction(action: (WithProfile) -> OnTapAction) case custom(trailingAccessory: (WithProfile) -> SessionCell.Accessory, onTap: (UserListViewModel?, WithProfile) -> Void) @@ -79,7 +79,7 @@ class UserListViewModel: SessionTableVie public enum OnSubmitAction { case none - case callback((UserListViewModel?, Set>) throws -> Void) + case callback((UserListViewModel?, Set>) async throws -> Void) case publisher((UserListViewModel?, Set>) -> AnyPublisher) var hasAction: Bool { @@ -176,7 +176,11 @@ class UserListViewModel: SessionTableVie // Trigger any 'onTap' actions switch finalAction { case .none: return - case .callback(let callback): callback(self, userInfo) + case .callback(let callback): + Task.detached(priority: .userInitiated) { [weak self] in + await callback(self, userInfo) + } + case .custom(_, let callback): callback(self, userInfo) case .radio: break case .conditionalAction(_): return // Shouldn't hit this case @@ -224,25 +228,32 @@ class UserListViewModel: SessionTableVie case .none: return case .callback(let submission): - do { - try submission(self, selectedUsers) - selectedUsersSubject.send([]) - forceRefresh() // Just in case the filter was impacted - } - catch { - transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "theError".localized(), - body: .text(error.localizedDescription), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text + Task.detached(priority: .userInitiated) { [weak self] in + do { + try await submission(self, selectedUsers) + await MainActor.run { [weak self] in + self?.selectedUsersSubject.send([]) + self?.forceRefresh() // Just in case the filter was impacted + } + } + catch { + await MainActor.run { [weak self] in + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text(error.localizedDescription), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ), + transitionType: .present ) - ), - transitionType: .present - ) + } + } } + case .publisher(let submission): transitionToScreen( ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index b03942a719..1bab3eec22 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -32,22 +32,15 @@ public final class BackgroundPoller { ( try ClosedGroup .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == dependencies[cache: .general].sessionId.hexString) - ) + .filter(ClosedGroup.Columns.shouldPoll) .asRequest(of: String.self) .fetchSet(db), - /// The default room promise creates an OpenGroup with an empty `roomToken` value, we - /// don't want to start a poller for this as the user hasn't actually joined a room - /// - /// We also want to exclude any rooms which have failed to poll too many times in a row from + /// We want to exclude any rooms which have failed to poll too many times in a row from /// the background poll as they are likely to fail again try OpenGroup .select(.server) .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && + OpenGroup.Columns.shouldPoll == true && OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll ) .distinct() @@ -56,8 +49,7 @@ public final class BackgroundPoller { try OpenGroup .select(.roomToken) .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && + OpenGroup.Columns.shouldPoll == true && OpenGroup.Columns.pollFailureCount < CommunityPoller.maxRoomFailureCountForBackgroundPoll ) .distinct() diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 7712a71369..b6b9edf22a 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -325,7 +325,7 @@ enum MockDataGenerator { server: serverName, roomToken: roomName, publicKey: randomGroupPublicKey, - isActive: true, + shouldPoll: true, name: roomName, roomDescription: roomDescription, userCount: numGroupMembers, diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index c06ec2dd89..ac28f6e100 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -102,18 +102,20 @@ public extension UIContextualAction { tableView: tableView ) { _, _, completionHandler in // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + Task.detached(priority: .userInitiated) { + try await Task.sleep(for: unswipeAnimationDelay) switch isUnread { - case true: threadViewModel.markAsRead( + case true: try? await threadViewModel.markAsRead( target: .threadAndInteractions( interactionsBeforeInclusive: threadViewModel.interactionId ), using: dependencies ) - case false: threadViewModel.markAsUnread(using: dependencies) + case false: try? await threadViewModel.markAsUnread(using: dependencies) } } + completionHandler(true) } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 4e71f0f955..9849d0f243 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -49,7 +49,8 @@ public enum SNMessagingKit { _043_RenameAttachments.self, _044_AddProMessageFlag.self, _045_LastProfileUpdateTimestamp.self, - _046_RemoveQuoteUnusedColumnsAndForeignKeys.self + _046_RemoveQuoteUnusedColumnsAndForeignKeys.self, + _047_DropUnneededColumnsAndTables.self ] public static func configure(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift index 6c9e861ca9..f7dbbabe52 100644 --- a/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_006_SMK_InitialSetupMigration.swift @@ -13,7 +13,7 @@ enum _006_SMK_InitialSetupMigration: Migration { Contact.self, Profile.self, SessionThread.self, DisappearingMessagesConfiguration.self, ClosedGroup.self, OpenGroup.self, Capability.self, BlindedIdLookup.self, GroupMember.self, Interaction.self, Attachment.self, InteractionAttachment.self, Quote.self, - LinkPreview.self, ThreadTypingIndicator.self + LinkPreview.self ] public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = { diff --git a/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift b/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift new file mode 100644 index 0000000000..d75d33135c --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_047_DropUnneededColumnsAndTables.swift @@ -0,0 +1,37 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _047_DropUnneededColumnsAndTables: Migration { + static let identifier: String = "DropUnneededColumnsAndTables" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + /// This is now handled entirely in memory (previously we had this in the database because UI updates were driven by database + /// changes, but now they are driven via our even observation system - removing this from the database means we no longer need + /// to deal with cleaning up entries on launch as well) + try db.drop(table: "threadTypingIndicator") + + try db.alter(table: "openGroup") { t in + /// We previously stored the "default" communities in the database as, in the past, we wanted to show them immediately + /// regardless of whether we have network connectivity - that changed a while back where we now only want to show them + /// if they are "correct" + /// + /// Instead of removing this column we are repurposing it to `shouldPoll` as, while we don't currently have a mechanism + /// to disable polling a comminuty, it's likley adding one in the future would be beneficial + t.rename(column: "isActive", to: "shouldPoll") + } + + /// When we were storing the "default" communities we added an entry to the database which had an empty `roomToken`, as + /// a result we needed a bunch of checks to ensure we wouldn't include this when doing any operations related to the communities + /// explicitly joined by the user + /// + /// Now that these "default" communities exist solely in memory we can discard these entries + try OpenGroup.filter(OpenGroup.Columns.roomToken == "").deleteAll(db) + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index f5bff7ce4a..f5be8c6e58 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -12,14 +12,6 @@ import SessionUIKit public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } - internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) - public static let interactionAttachments = hasOne(InteractionAttachment.self) - public static let interaction = hasOne( - Interaction.self, - through: interactionAttachments, - using: InteractionAttachment.interaction - ) - fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -42,7 +34,7 @@ public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, case caption } - public enum Variant: Int, Sendable, Codable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, CaseIterable, DatabaseValueConvertible { case standard case voiceMessage } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 99131cbffa..2751012992 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -10,9 +10,6 @@ import SessionUtilitiesKit public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroup" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - public static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -67,36 +64,6 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable /// A flag indicating whether this group is in the "expired" state (ie. it's config messages no longer exist) public let expired: Bool? - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: ClosedGroup.thread) - } - - public var allMembers: QueryInterfaceRequest { - request(for: ClosedGroup.members) - } - - public var members: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - } - - public var zombies: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.zombie) - } - - public var moderators: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.moderator) - } - - public var admins: QueryInterfaceRequest { - request(for: ClosedGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 8bc35627d4..86ca6e0d2e 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -107,4 +107,25 @@ extension Contact: ProfileAssociated { return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased()) } + + public func with( + isTrusted: Update = .useExisting, + isApproved: Update = .useExisting, + isBlocked: Update = .useExisting, + lastKnownClientVersion: Update = .useExisting, + didApproveMe: Update = .useExisting, + hasBeenBlocked: Update = .useExisting, + currentUserSessionId: SessionId + ) -> Contact { + return Contact( + id: id, + isTrusted: isTrusted.or(self.isTrusted), + isApproved: isApproved.or(self.isApproved), + isBlocked: isBlocked.or(self.isBlocked), + lastKnownClientVersion: lastKnownClientVersion.or(self.lastKnownClientVersion), + didApproveMe: didApproveMe.or(self.didApproveMe), + hasBeenBlocked: hasBeenBlocked.or(self.hasBeenBlocked), + currentUserSessionId: currentUserSessionId + ) + } } diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 4d3ae305f3..8822207599 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -7,10 +7,8 @@ import SessionUtil import SessionUtilitiesKit import SessionNetworkingKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "disappearingMessagesConfiguration" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -37,7 +35,7 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl } } - public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible { + public enum DisappearingMessageType: Int, Sendable, Codable, Hashable, DatabaseValueConvertible { case unknown case disappearAfterRead case disappearAfterSend @@ -107,12 +105,6 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl public let isEnabled: Bool public let durationSeconds: TimeInterval public var type: DisappearingMessageType? - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: DisappearingMessagesConfiguration.thread) - } } // MARK: - Mutation diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 5df57bec91..8a0f5cc5d6 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -5,28 +5,10 @@ import GRDB import SessionUtilitiesKit import SessionNetworkingKit -public struct Interaction: Codable, Identifiable, Equatable, Hashable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, PagableRecord, FetchableRecord, MutablePersistableRecord, IdentifiableTableRecord, ColumnExpressible { + public typealias PagedDataType = Interaction 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 - ) - - /// 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 let idColumn: ColumnExpression = Columns.id // stringlint:ignore_contents public static func linkPreviewFilterLiteral( @@ -73,7 +55,7 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable case isProMessage } - public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable { + public enum Variant: Int, Sendable, Codable, Hashable, DatabaseValueConvertible, CaseIterable { case _legacyStandardIncomingDeleted = 2 // Had an incorrect index so broke this... case standardIncoming = 0 @@ -105,7 +87,7 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable case infoCall = 5000 } - public enum State: Int, Codable, Hashable, DatabaseValueConvertible { + public enum State: Int, Sendable, Codable, Hashable, DatabaseValueConvertible { case sending // Spacing out the values to allow for additional statuses in the future @@ -224,40 +206,6 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable /// A flag indicating if the message sender is a Session Pro user when the message is sent public let isProMessage: Bool - // 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 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) - ) - } - // MARK: - Initialization internal init( @@ -379,14 +327,14 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable } public func aroundInsert(_ db: Database, insert: () throws -> InsertionSuccess) throws { - _ = try insert() + let result: InsertionSuccess = try insert() // Start the disappearing messages timer if needed switch ObservationContext.observingDb { case .none: Log.error("[Interaction] Could not process 'aroundInsert' due to missing observingDb.") case .some(let observingDb): observingDb.dependencies.setAsync(.hasSavedMessage, true) - observingDb.addMessageEvent(id: id, threadId: threadId, type: .created) + observingDb.addMessageEvent(id: result.rowID, threadId: threadId, type: .created) if self.expiresStartedAtMs != nil { observingDb.dependencies[singleton: .jobRunner].upsert( @@ -632,7 +580,7 @@ public extension Interaction { _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - db.addConversationEvent(id: threadId, type: .updated(.unreadCountChanged)) + db.addConversationEvent(id: threadId, type: .updated(.unreadCount)) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -691,7 +639,7 @@ public extension Interaction { interactionInfoToMarkAsRead.forEach { info in db.addMessageEvent(id: info.id, threadId: threadId, type: .updated(.wasRead(true))) } - db.addConversationEvent(id: threadId, type: .updated(.unreadCountChanged)) + db.addConversationEvent(id: threadId, type: .updated(.unreadCount)) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -898,7 +846,7 @@ public extension Interaction { let body: String } - struct TimestampInfo: FetchableRecord, Codable { + struct TimestampInfo: FetchableRecord, Sendable, Codable, Equatable { public let id: Int64 public let timestampMs: Int64 @@ -966,6 +914,73 @@ public extension Interaction { // MARK: - Functions + // stringlint:ignore_contents + static func attachments( + interactionId: Int64? + ) -> SQLRequest? { + guard let interactionId: Int64 = interactionId else { return nil } + + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + SELECT * + FROM \(attachment) + JOIN \(interactionAttachment) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) + WHERE \(interactionAttachment[.interactionId]) = \(interactionId) + ORDER BY \(interactionAttachment[.albumIndex]) + """ + } + + // stringlint:ignore_contents + static func attachmentDescription( + _ db: ObservingDatabase, + interactionId: Int64? + ) throws -> Attachment.DescriptionInfo? { + guard let interactionId: Int64 = interactionId else { return nil } + + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + let request: SQLRequest = """ + SELECT + \(attachment[.id]), + \(attachment[.variant]), + \(attachment[.contentType]), + \(attachment[.sourceFilename]) + FROM \(attachment) + JOIN \(interactionAttachment) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + WHERE \(interactionAttachment[.interactionId]) = \(interactionId) + ORDER BY \(interactionAttachment[.albumIndex]) + LIMIT 1 + """ + + return try request.fetchOne(db) + } + + // stringlint:ignore_contents + static func linkPreview( + url: String?, + timestampMs: Int64, + variants: [LinkPreview.Variant] = LinkPreview.Variant.allCases + ) -> SQLRequest? { + guard let url: String = url else { return nil } + + let linkPreview: TypedTableAlias = TypedTableAlias() + let minTimestamp: Int64 = (timestampMs - Int64(LinkPreview.timstampResolution * 1000)) + let maxTimestamp: Int64 = (timestampMs + Int64(LinkPreview.timstampResolution * 1000)) + + /// This logic **MUST** always match the `linkPreviewFilterLiteral` logic + return """ + SELECT * + FROM \(linkPreview) + WHERE ( + \(linkPreview[.url]) = \(url) AND + (\(linkPreview[.timestamp]) BETWEEN (\(minTimestamp) AND \(maxTimestamp)) AND + \(linkPreview[.variant]) IN \(variants) + ) + """ + } + 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( @@ -1054,14 +1069,29 @@ public extension Interaction { return Interaction.previewText( variant: interaction.variant, body: interaction.body, - attachmentDescriptionInfo: try? interaction.attachments - .select(.id, .variant, .contentType, .sourceFilename) - .asRequest(of: Attachment.DescriptionInfo.self) - .fetchOne(db), - attachmentCount: try? interaction.attachments.fetchCount(db), - isOpenGroupInvitation: interaction.linkPreview - .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - .isNotEmpty(db), + attachmentDescriptionInfo: try? Interaction.attachmentDescription( + db, + interactionId: interaction.id + ), + attachmentCount: try? { + guard let interactionId = interaction.id else { return 0 } + + return try InteractionAttachment + .filter(InteractionAttachment.Columns.interactionId == interactionId) + .fetchCount(db) + }(), + isOpenGroupInvitation: { + guard + let request: SQLRequest = Interaction.linkPreview( + url: interaction.linkPreviewUrl, + timestampMs: interaction.timestampMs, + variants: [.openGroupInvitation] + ), + let count: Int = try? request.fetchCount(db) + else { return false } + + return (count > 0) + }(), using: dependencies ) @@ -1326,7 +1356,7 @@ public extension Interaction { } } - private struct InteractionVariantInfo: Codable, FetchableRecord { + struct VariantInfo: Codable, FetchableRecord { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id @@ -1407,10 +1437,10 @@ public extension Interaction { options: DeletionOption, using dependencies: Dependencies ) throws { - let interactionInfo: [InteractionVariantInfo] = try Interaction + let interactionInfo: [VariantInfo] = try Interaction .filter(ids: interactionIds) .select(.id, .variant, .serverHash) - .asRequest(of: InteractionVariantInfo.self) + .asRequest(of: VariantInfo.self) .fetchAll(db) /// Mark the messages as read just in case @@ -1446,10 +1476,14 @@ public extension Interaction { let interactionAttachments: [InteractionAttachment] = try InteractionAttachment .filter(interactionIds.contains(InteractionAttachment.Columns.interactionId)) .fetchAll(db) - let attachments: [Attachment] = try Attachment - .joining(required: Attachment.interaction.filter(interactionIds.contains(Interaction.Columns.id))) + let attachmentDownloadUrls: [String] = try Attachment + .select(.downloadUrl) + .filter(ids: interactionAttachments.map { $0.attachmentId }) + .asRequest(of: String.self) .fetchAll(db) - try attachments.forEach { try $0.delete(db) } + try Attachment + .filter(ids: interactionAttachments.map { $0.attachmentId }) + .deleteAll(db) /// Notify about the attachment deletion interactionAttachments.forEach { info in @@ -1481,6 +1515,10 @@ public extension Interaction { .asSet() try LoggingDatabaseRecordContext.$suppressLogs.withValue(true) { try Interaction.deleteAll(db, ids: infoMessageIds) + + infoMessageIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .deleted) + } } let localOnly: Bool = (options.contains(.local) && !options.contains(.network)) @@ -1508,6 +1546,11 @@ public extension Interaction { .filter(ids: info.map { $0.id }) .deleteAll(db) } + + /// Notify about the deletion + interactionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .deleted) + } } else { try Interaction .filter(ids: info.map { $0.id }) @@ -1520,20 +1563,20 @@ public extension Interaction { Interaction.Columns.linkPreviewUrl.set(to: nil), Interaction.Columns.state.set(to: Interaction.State.deleted) ) + + /// Notify about the deletion + interactionIds.forEach { id in + db.addMessageEvent(id: id, threadId: threadId, type: .updated(.markedAsDeleted)) + } } } - /// Notify about the deletion - interactionIds.forEach { id in - db.addMessageEvent(id: id, threadId: threadId, type: .deleted) - } - /// If we had attachments then we want to try to delete their associated files immediately (in the next run loop) as that's the /// behaviour users would expect, if this fails for some reason then they will be cleaned up by the `GarbageCollectionJob` /// but we should still try to handle it immediately - if !attachments.isEmpty { - let attachmentPaths: [String] = attachments.compactMap { - try? dependencies[singleton: .attachmentManager].path(for: $0.downloadUrl) + if !attachmentDownloadUrls.isEmpty { + let attachmentPaths: [String] = attachmentDownloadUrls.compactMap { + try? dependencies[singleton: .attachmentManager].path(for: $0) } DispatchQueue.global(qos: .background).async { diff --git a/SessionMessagingKit/Database/Models/InteractionAttachment.swift b/SessionMessagingKit/Database/Models/InteractionAttachment.swift index 05feb2132e..675e8c288e 100644 --- a/SessionMessagingKit/Database/Models/InteractionAttachment.swift +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -4,12 +4,8 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct InteractionAttachment: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct InteractionAttachment: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "interactionAttachment" } - internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id]) - public static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) - internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -22,16 +18,6 @@ public struct InteractionAttachment: Codable, Equatable, FetchableRecord, Persis public let interactionId: Int64 public let attachmentId: String - // MARK: - Relationships - - public var interaction: QueryInterfaceRequest { - request(for: InteractionAttachment.interaction) - } - - public var attachment: QueryInterfaceRequest { - request(for: InteractionAttachment.attachment) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 3452285efc..7e8487181d 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -10,17 +10,11 @@ import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit -public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct LinkPreview: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "linkPreview" } - internal static let interactionForeignKey = ForeignKey( - [Columns.url], - to: [Interaction.Columns.linkPreviewUrl] - ) - internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) - public static let attachment = hasOne(Attachment.self, using: Attachment.linkPreviewForeignKey) /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale - internal static let timstampResolution: Double = 100000 + public static let timstampResolution: Double = 100000 internal static let maxImageDimension: CGFloat = 600 public typealias Columns = CodingKeys @@ -32,7 +26,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis case attachmentId } - public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, Hashable, CaseIterable, DatabaseValueConvertible { case standard case openGroupInvitation } @@ -53,12 +47,6 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis /// The id for the attachment for the link preview image public let attachmentId: String? - // MARK: - Relationships - - public var attachment: QueryInterfaceRequest { - request(for: LinkPreview.attachment) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 977887c1a1..64802fa5c3 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -9,9 +9,6 @@ import SessionUtilitiesKit public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "openGroup" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - public static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -20,7 +17,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case roomToken case publicKey case name - case isActive + case shouldPoll case roomDescription = "description" case imageId case userCount @@ -41,6 +38,16 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe self.rawValue = rawValue } + public init(read: Bool, write: Bool, upload: Bool) { + var permissions: Permissions = [] + + if read { permissions.insert(.read) } + if write { permissions.insert(.write) } + if upload { permissions.insert(.upload) } + + self.init(rawValue: permissions.rawValue) + } + public init(roomInfo: Network.SOGS.RoomPollInfo) { var permissions: Permissions = [] @@ -91,9 +98,8 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// The public key for the group public let publicKey: String - /// Flag indicating whether this is an `OpenGroup` the user has actively joined (we store inactive - /// open groups so we can display them in the UI but they won't be polled for) - public let isActive: Bool + /// A flag indicating whether we should poll for messages in this community + public let shouldPoll: Bool /// The name for the group public let name: String @@ -137,22 +143,6 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe /// a different hash being generated for existing files - this value also won't be updated until the display picture has actually /// been downloaded public let displayPictureOriginalUrl: String? - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: OpenGroup.thread) - } - - public var moderatorIds: QueryInterfaceRequest { - request(for: OpenGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.moderator) - } - - public var adminIds: QueryInterfaceRequest { - request(for: OpenGroup.members) - .filter(GroupMember.Columns.role == GroupMember.Role.admin) - } // MARK: - Initialization @@ -160,7 +150,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe server: String, roomToken: String, publicKey: String, - isActive: Bool, + shouldPoll: Bool, name: String, roomDescription: String? = nil, imageId: String? = nil, @@ -177,7 +167,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe self.server = server.lowercased() self.roomToken = roomToken self.publicKey = publicKey - self.isActive = isActive + self.shouldPoll = shouldPoll self.name = name self.roomDescription = roomDescription self.imageId = imageId @@ -206,7 +196,7 @@ public extension OpenGroup { server: server, roomToken: roomToken, publicKey: publicKey, - isActive: false, + shouldPoll: false, name: roomToken, // Default the name to the `roomToken` until we get retrieve the actual name roomDescription: nil, imageId: nil, @@ -239,6 +229,26 @@ public extension OpenGroup { // Always force the server to lowercase return "\(server.lowercased()).\(roomToken)" } + + func with(shouldPoll: Bool, sequenceNumber: Int64) -> OpenGroup { + return OpenGroup( + server: server, + roomToken: roomToken, + publicKey: publicKey, + shouldPoll: shouldPoll, + name: name, + roomDescription: roomDescription, + imageId: imageId, + userCount: userCount, + infoUpdates: infoUpdates, + sequenceNumber: sequenceNumber, + inboxLatestMessageId: inboxLatestMessageId, + outboxLatestMessageId: outboxLatestMessageId, + pollFailureCount: pollFailureCount, + permissions: permissions, + displayPictureOriginalUrl: displayPictureOriginalUrl + ) + } } extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { @@ -250,7 +260,7 @@ extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { roomToken: \"\(roomToken)\", id: \"\(id)\", publicKey: \"\(publicKey)\", - isActive: \(isActive), + shouldPoll: \(shouldPoll), name: \"\(name)\", roomDescription: \(roomDescription.map { "\"\($0)\"" } ?? "null"), imageId: \(imageId ?? "null"), diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 8373c22bd3..d66c083a90 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Quote: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } public typealias Columns = CodingKeys diff --git a/SessionMessagingKit/Database/Models/Reaction.swift b/SessionMessagingKit/Database/Models/Reaction.swift index 4c5557a5d8..6fb4a1ab5c 100644 --- a/SessionMessagingKit/Database/Models/Reaction.swift +++ b/SessionMessagingKit/Database/Models/Reaction.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct Reaction: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Reaction: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "reaction" } internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index c3893cbeb1..22637a17ef 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -9,19 +9,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab public static var databaseTableName: String { "thread" } public static let idColumn: ColumnExpression = Columns.id - 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 @@ -84,32 +71,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab /// 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: - 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( @@ -392,6 +353,12 @@ public extension SessionThread { threadVariant: variant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: id, + 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 } @@ -403,6 +370,12 @@ public extension SessionThread { threadVariant: variant, using: dependencies ) + + /// Notify of update + db.addConversationEvent( + id: id, + type: .updated(.disappearingMessageConfiguration(config)) + ) case (_, .useLibSession): break // Shouldn't happen } @@ -530,6 +503,35 @@ public extension SessionThread { ) """) } + + // stringlint:ignore_contents + static func interactionInfoWithAttachments( + threadId: String, + beforeTimestampMs: Int64? = nil, + attachmentVariants: [Attachment.Variant] = Attachment.Variant.allCases + ) throws -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = 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 @@ -700,7 +702,7 @@ public extension SessionThread { case .deleteCommunityAndContent: try threadIds.forEach { threadId in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: threadId, skipLibSessionUpdate: false diff --git a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift b/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift deleted file mode 100644 index bad5e96dde..0000000000 --- a/SessionMessagingKit/Database/Models/ThreadTypingIndicator.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -/// This record is created for an incoming typing indicator message -/// -/// **Note:** Currently we only support typing indicator on contact thread (one-to-one), to support groups we would need -/// to change the structure of this table (since it’s primary key is the threadId) -public struct ThreadTypingIndicator: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "threadTypingIndicator" } - internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) - private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case threadId - case timestampMs - } - - public let threadId: String - public let timestampMs: Int64 - - // MARK: - Relationships - - public var thread: QueryInterfaceRequest { - request(for: ThreadTypingIndicator.thread) - } -} diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 483be09a87..2c5697ff89 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -374,51 +374,6 @@ extension DisplayPictureDownloadJob { self.timestamp = timestamp } - public init?(owner: DisplayPictureManager.Owner) { - switch owner { - case .user(let profile): - guard - let url: String = profile.displayPictureUrl, - let key: Data = profile.displayPictureEncryptionKey, - let details: Details = Details( - target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: profile.profileLastUpdated - ) - else { return nil } - - self = details - - case .group(let group): - guard - let url: String = group.displayPictureUrl, - let key: Data = group.displayPictureEncryptionKey, - let details: Details = Details( - target: .group(id: group.id, url: url, encryptionKey: key), - timestamp: nil - ) - else { return nil } - - self = details - - case .community(let openGroup): - guard - let imageId: String = openGroup.imageId, - let details: Details = Details( - target: .community( - imageId: imageId, - roomToken: openGroup.roomToken, - server: openGroup.server - ), - timestamp: nil - ) - else { return nil } - - self = details - - case .file: return nil - } - } - // MARK: - Functions fileprivate func ensureValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index 9a85a06a1b..451e95dace 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -56,42 +56,18 @@ public enum GarbageCollectionJob: JobExecutor { /// are shown) let lastGarbageCollection: Date = dependencies[defaults: .standard, key: .lastGarbageCollection] .defaulting(to: Date.distantPast) - let finalTypesToCollect: Set = { - guard - job.behaviour != .recurringOnActive || - dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) - else { - // Note: This should only contain the `Types` which are unlikely to ever cause - // a startup delay (ie. avoid mass deletions and file management) - return typesToCollect.asSet() - .intersection([ - .threadTypingIndicators - ]) - } - - return typesToCollect.asSet() - }() + + guard + job.behaviour != .recurringOnActive || + dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) + else { return } dependencies[singleton: .storage].writeAsync( updates: { db -> FileInfo in let userSessionId: SessionId = dependencies[cache: .general].sessionId - /// Remove any typing indicators - if finalTypesToCollect.contains(.threadTypingIndicators) { - let threadIds: Set = try ThreadTypingIndicator - .select(.threadId) - .asRequest(of: String.self) - .fetchSet(db) - _ = try ThreadTypingIndicator.deleteAll(db) - - /// Just in case we should emit events for each typing indicator to indicate that it should have stopped typing - threadIds.forEach { id in - db.addTypingIndicatorEvent(threadId: id, change: .stopped) - } - } - /// Remove any old open group messages - open group messages which are older than six months - if finalTypesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) { + if typesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) @@ -122,7 +98,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned jobs - jobs which have had their threads or interactions removed - if finalTypesToCollect.contains(.orphanedJobs) { + if typesToCollect.contains(.orphanedJobs) { let job: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -150,7 +126,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps - if finalTypesToCollect.contains(.orphanedLinkPreviews) { + if typesToCollect.contains(.orphanedLinkPreviews) { let linkPreview: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -170,7 +146,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which /// we want cached image data even if the user isn't in the group) - if finalTypesToCollect.contains(.orphanedOpenGroups) { + if typesToCollect.contains(.orphanedOpenGroups) { let openGroup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -189,7 +165,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server - if finalTypesToCollect.contains(.orphanedOpenGroupCapabilities) { + if typesToCollect.contains(.orphanedOpenGroupCapabilities) { let capability: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -205,7 +181,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id - if finalTypesToCollect.contains(.orphanedBlindedIdLookups) { + if typesToCollect.contains(.orphanedBlindedIdLookups) { let blindedIdLookup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() @@ -233,7 +209,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded /// contact record around anymore - if finalTypesToCollect.contains(.approvedBlindedContactRecords) { + if typesToCollect.contains(.approvedBlindedContactRecords) { let contact: TypedTableAlias = TypedTableAlias() let blindedIdLookup: TypedTableAlias = TypedTableAlias() @@ -252,7 +228,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned attachments - attachments which have no related interactions, quotes or link previews - if finalTypesToCollect.contains(.orphanedAttachments) { + if typesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() @@ -272,7 +248,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - if finalTypesToCollect.contains(.orphanedProfiles) { + if typesToCollect.contains(.orphanedProfiles) { let profile: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -308,7 +284,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Remove interactions which should be disappearing after read but never be read within 14 days - if finalTypesToCollect.contains(.expiredUnreadDisappearingMessages) { + if typesToCollect.contains(.expiredUnreadDisappearingMessages) { try Interaction.deleteWhere( db, .filter(Interaction.Columns.expiresInSeconds != 0), @@ -317,13 +293,13 @@ public enum GarbageCollectionJob: JobExecutor { ) } - if finalTypesToCollect.contains(.expiredPendingReadReceipts) { + if typesToCollect.contains(.expiredPendingReadReceipts) { _ = try PendingReadReceipt .filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } - if finalTypesToCollect.contains(.shadowThreads) { + if typesToCollect.contains(.shadowThreads) { // Shadow threads are thread records which were created to start a conversation that // didn't actually get turned into conversations (ie. the app was closed or crashed // before the user sent a message) @@ -351,7 +327,7 @@ public enum GarbageCollectionJob: JobExecutor { """) } - if finalTypesToCollect.contains(.pruneExpiredLastHashRecords) { + if typesToCollect.contains(.pruneExpiredLastHashRecords) { // Delete any expired SnodeReceivedMessageInfo values associated to a specific node try SnodeReceivedMessageInfo .select(Column.rowID) @@ -365,7 +341,7 @@ public enum GarbageCollectionJob: JobExecutor { var messageDedupeRecords: [MessageDeduplication] = [] /// Orphaned attachment files - attachment files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) @@ -378,7 +354,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned display picture files - profile avatar files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedDisplayPictures) { + if typesToCollect.contains(.orphanedDisplayPictures) { displayPictureFilePaths.insert( contentsOf: Set(try Profile .select(.displayPictureUrl) @@ -405,7 +381,7 @@ public enum GarbageCollectionJob: JobExecutor { ) } - if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + if typesToCollect.contains(.pruneExpiredDeduplicationRecords) { messageDedupeRecords = try MessageDeduplication .filter( MessageDeduplication.Columns.expirationTimestampSeconds != nil && @@ -430,7 +406,7 @@ public enum GarbageCollectionJob: JobExecutor { var deletionErrors: [Error] = [] /// Orphaned attachment files (actual deletion) - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { let attachmentDirPath: String = dependencies[singleton: .attachmentManager] .sharedDataAttachmentsDirPath() let allAttachmentFilePaths: Set = (Set((try? dependencies[singleton: .fileManager] @@ -457,7 +433,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned display picture files (actual deletion) - if finalTypesToCollect.contains(.orphanedDisplayPictures) { + if typesToCollect.contains(.orphanedDisplayPictures) { let allDisplayPictureFilePaths: Set = (try? dependencies[singleton: .fileManager] .contentsOfDirectory(atPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath())) .defaulting(to: []) @@ -482,7 +458,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Explicit deduplication records that we want to delete - if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + if typesToCollect.contains(.pruneExpiredDeduplicationRecords) { fileInfo.messageDedupeRecords.forEach { record in /// We don't want a single deletion failure to block deletion of the other files so try each one and store /// the error to be used to determine success/failure of the job @@ -543,7 +519,6 @@ public enum GarbageCollectionJob: JobExecutor { extension GarbageCollectionJob { public enum Types: Codable, CaseIterable { - case threadTypingIndicators case oldOpenGroupMessages case orphanedJobs case orphanedLinkPreviews diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index e34c48694b..20f703e010 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -40,143 +40,75 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { .isEmpty else { return deferred(job) } - // The Network.SOGS won't make any API calls if there is no entry for an OpenGroup - // in the database so we need to create a dummy one to retrieve the default room data - let defaultGroupId: String = OpenGroup.idFor(roomToken: "", server: Network.SOGS.defaultServer) - - dependencies[singleton: .storage].write { db in - guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } - - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "", - userCount: 0, - infoUpdates: 0 - ) - .upserted(db) - } - - /// Try to retrieve the default rooms 8 times - dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> AuthenticationMethod in - try Authentication.with( - db, - server: Network.SOGS.defaultServer, - activeOnly: false, /// The record for the default rooms is inactive - using: dependencies - ) - } - .tryFlatMap { [dependencies] authMethod -> AnyPublisher<(ResponseInfoType, Network.SOGS.CapabilitiesAndRoomsResponse), Error> in - try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: authMethod, + Task { + do { + let request = try Network.SOGS.preparedCapabilitiesAndRooms( + authMethod: Network.SOGS.defaultAuthMethod, skipAuthentication: true, using: dependencies - ).send(using: dependencies) - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .retry(8, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: - Log.info(.cat, "Successfully retrieved default Community rooms") - success(job, false) + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SOGS.CapabilitiesAndRoomsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + guard !Task.isCancelled else { return } + + /// Store the updated capabilities and schedule downloads for the room images (if they + /// are already downloaded then the job will just complete) + try await dependencies[singleton: .storage].writeAsync { db in + dependencies[singleton: .communityManager].handleCapabilities( + db, + capabilities: response.capabilities.data, + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey + ) + + response.rooms.data.forEach { info in + guard let imageId: String = info.imageId else { return } - case .failure(let error): - Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") - failure(job, error, false) - } - }, - receiveValue: { info, response in - let defaultRooms: [OpenGroupManager.DefaultRoomInfo]? = dependencies[singleton: .storage].write { db -> [OpenGroupManager.DefaultRoomInfo] in - // Store the capabilities first - OpenGroupManager.handleCapabilities( + dependencies[singleton: .jobRunner].add( db, - capabilities: response.capabilities.data, - on: Network.SOGS.defaultServer + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: imageId, + roomToken: info.token, + server: Network.SOGS.defaultServer + ), + timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ) + ), + canStartJob: true ) - - let existingImageIds: [String: String] = try OpenGroup - .filter(OpenGroup.Columns.server == Network.SOGS.defaultServer) - .filter(OpenGroup.Columns.imageId != nil) - .fetchAll(db) - .reduce(into: [:]) { result, next in result[next.id] = next.imageId } - let result: [OpenGroupManager.DefaultRoomInfo] = try response.rooms.data - .compactMap { room -> OpenGroupManager.DefaultRoomInfo? in - /// Try to insert an inactive version of the OpenGroup (use `insert` rather than - /// `save` as we want it to fail if the room already exists) - do { - return ( - room, - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: room.token, - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: room.name, - roomDescription: room.roomDescription, - imageId: room.imageId, - userCount: room.activeUsers, - infoUpdates: room.infoUpdates - ) - .inserted(db) - ) - } - catch { - return try OpenGroup - .fetchOne( - db, - id: OpenGroup.idFor( - roomToken: room.token, - server: Network.SOGS.defaultServer - ) - ) - .map { (room, $0) } - } - } - - /// Schedule the room image download (if it doesn't match out current one) - result.forEach { room, openGroup in - let openGroupId: String = OpenGroup.idFor(roomToken: room.token, server: Network.SOGS.defaultServer) - - guard - let imageId: String = room.imageId, - imageId != existingImageIds[openGroupId] || - openGroup.displayPictureOriginalUrl == nil - else { return } - - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: imageId, - roomToken: room.token, - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ) - ), - canStartJob: true - ) - } - - return result - } - - /// Update the `openGroupManager` cache to have the default rooms - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setDefaultRoomInfo(defaultRooms ?? []) } } - ) + + /// Update the `CommunityManager` cache of room and capability data + await dependencies[singleton: .communityManager].updateRooms( + rooms: response.rooms.data, + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) + Log.info(.cat, "Successfully retrieved default Community rooms") + + scheduler.schedule { + success(job, false) + } + } + catch { + /// We want to fail permanently here, otherwise we would just indefinitely retry (if the user opens the + /// "Join Community" screen that will kick off another job, otherwise this will automatically be rescheduled + /// on launch) + Log.error(.cat, "Failed to get default Community rooms due to error: \(error)") + scheduler.schedule { + failure(job, error, true) + } + } + } } public static func run(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 5687e06d1d..90ed53626b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -315,13 +315,16 @@ public extension LibSession { // want the extensions to trigger this as it can clog up their networking) if let updatedProfile: Profile = info.profile, + let newUrl: String = info.displayPictureUrl, + let newKey: Data = info.displayPictureEncryptionKey, dependencies[singleton: .appContext].isMainApp && ( - oldAvatarUrl != (info.displayPictureUrl ?? "") || - oldAvatarKey != (info.displayPictureEncryptionKey ?? Data()) + oldAvatarUrl != newUrl || + oldAvatarKey != newKey ) { dependencies[singleton: .displayPictureManager].scheduleDownload( - for: .user(updatedProfile) + for: .profile(id: updatedProfile.id, url: newUrl, encryptionKey: newKey), + timestamp: updatedProfile.profileLastUpdated ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 82cdc5060c..344fcd2b07 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -233,17 +233,12 @@ internal extension LibSessionCacheType { let attachDeleteBeforeTimestamp: Int64 = groups_info_get_attach_delete_before(conf) if attachDeleteBeforeTimestamp > 0 { - let interactionInfo: [InteractionInfo] = (try? Interaction - .filter(Interaction.Columns.threadId == groupSessionId.hexString) - .filter(Interaction.Columns.timestampMs < (TimeInterval(attachDeleteBeforeTimestamp) * 1000)) - .joining( - required: Interaction.interactionAttachments.joining( - required: InteractionAttachment.attachment - .filter(Attachment.Columns.variant != Attachment.Variant.voiceMessage) - ) + let interactionInfo: [Interaction.VariantInfo] = (try? SessionThread + .interactionInfoWithAttachments( + threadId: groupSessionId.hexString, + beforeTimestampMs: Int64(floor(TimeInterval(attachDeleteBeforeTimestamp) * 1000)), + attachmentVariants: [.standard] ) - .select(.id, .serverHash) - .asRequest(of: InteractionInfo.self) .fetchAll(db)) .defaulting(to: []) let interactionIdsToRemove: Set = Set(interactionInfo.map { $0.id }) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index f8459b524a..ade373d71f 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -51,9 +51,9 @@ internal extension LibSessionCacheType { .asSet() // Add in any new members and remove any removed members - try updatedMembers - .subtracting(existingMembers) - .forEach { try $0.upsert(db) } + let newMembers: Set = updatedMembers.subtracting(existingMembers) + let removedMembers: Set = existingMembers.subtracting(updatedMembers) + try newMembers.forEach { try $0.upsert(db) } try GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) @@ -68,6 +68,38 @@ internal extension LibSessionCacheType { ) .deleteAll(db) + // Notify of any member/role changes + newMembers.forEach { member in + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .created + ) + } + + removedMembers.forEach { member in + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .deleted + ) + } + + updatedMembers.forEach { member in + guard + let existingMember: GroupMember = existingMembers.first(where: { $0.profileId == member.profileId }), ( + existingMember.role != member.role || + existingMember.roleStatus != member.roleStatus + ) + else { return } + + db.addGroupMemberEvent( + profileId: member.profileId, + threadId: groupSessionId.hexString, + type: .updated(.role(role: member.role, status: member.roleStatus)) + ) + } + // Schedule a job to process the removals if (try? LibSession.extractPendingRemovals(from: conf, groupSessionId: groupSessionId))?.isEmpty == false { dependencies[singleton: .jobRunner].add( @@ -116,6 +148,11 @@ internal extension LibSessionCacheType { status: .accepted, in: config ) + db.addGroupMemberEvent( + profileId: userSessionId.hexString, + threadId: groupSessionId.hexString, + type: .updated(.role(role: .admin, status: .accepted)) + ) } // If there were members then also extract and update the profile information for the members diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index ea081d9c1b..63fb76b77a 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -71,7 +71,7 @@ internal extension LibSessionCacheType { // Add any new communities (via the OpenGroupManager) extractedUserGroups.communities.forEach { community in - let successfullyAddedGroup: Bool = dependencies[singleton: .openGroupManager].add( + let successfullyAddedGroup: Bool = dependencies[singleton: .communityManager].add( db, roomToken: community.roomToken, server: community.server, @@ -81,7 +81,7 @@ internal extension LibSessionCacheType { if successfullyAddedGroup { db.afterCommit { [dependencies] in - dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( + dependencies[singleton: .communityManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, roomToken: community.roomToken, diff --git a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift index f9181fd85f..bdb7a90cb9 100644 --- a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift +++ b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift @@ -70,17 +70,15 @@ public extension LibSession { // MARK: - Queries - public static func fetchOne(_ db: ObservingDatabase, server: String, activeOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { + public static func fetchOne(_ db: ObservingDatabase, server: String, activelyPollingOnly: Bool = true) throws -> OpenGroupCapabilityInfo? { var query: QueryInterfaceRequest = OpenGroup .select(.threadId, .server, .roomToken, .publicKey) .filter(OpenGroup.Columns.server == server.lowercased()) .asRequest(of: OpenGroupUrlInfo.self) - /// If we only want to retrieve data for active OpenGroups then add additional filters - if activeOnly { - query = query - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + /// If we only want to retrieve data for OpenGroups we are actively polling then add additional filters + if activelyPollingOnly { + query = query.filter(OpenGroup.Columns.shouldPoll == true) } guard let urlInfo: OpenGroupUrlInfo = try query.fetchOne(db) else { return nil } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 5dbb460e75..119554851a 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -430,7 +430,7 @@ public extension Message { _ db: ObservingDatabase, openGroupId: String, message: Network.SOGS.Message, - associatedPendingChanges: [OpenGroupManager.PendingChange], + associatedPendingChanges: [CommunityManager.PendingChange], using dependencies: Dependencies ) -> [Reaction] { guard @@ -480,7 +480,7 @@ public extension Message { let pendingChangeSelfReaction: Bool? = { // Find the newest 'PendingChange' entry with a matching emoji, if one exists, and // set the "self reaction" value based on it's action - let maybePendingChange: OpenGroupManager.PendingChange? = associatedPendingChanges + let maybePendingChange: CommunityManager.PendingChange? = associatedPendingChanges .sorted(by: { lhs, rhs -> Bool in (lhs.seqNo ?? Int64.max) >= (rhs.seqNo ?? Int64.max) }) .first { pendingChange in if case .reaction(_, let emoji, _) = pendingChange.metadata { @@ -492,7 +492,7 @@ public extension Message { // If there is no pending change for this reaction then return nil guard - let pendingChange: OpenGroupManager.PendingChange = maybePendingChange, + let pendingChange: CommunityManager.PendingChange = maybePendingChange, case .reaction(_, _, let action) = pendingChange.metadata else { return nil } diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index 9cbe349c2a..846e40d193 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -3,5 +3,4 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber; FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[]; -#import #import diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift similarity index 50% rename from SessionMessagingKit/Open Groups/OpenGroupManager.swift rename to SessionMessagingKit/Open Groups/CommunityManager.swift index 85221c4c8a..c4acfa7892 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -9,41 +9,200 @@ import SessionNetworkingKit // MARK: - Singleton public extension Singleton { - static let openGroupManager: SingletonConfig = Dependencies.create( - identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager(using: dependencies) } - ) -} - -// MARK: - Cache - -public extension Cache { - static let openGroupManager: CacheConfig = Dependencies.create( - identifier: "openGroupManager", - createInstance: { dependencies in OpenGroupManager.Cache(using: dependencies) }, - mutableInstance: { $0 }, - immutableInstance: { $0 } + static let communityManager: SingletonConfig = Dependencies.create( + identifier: "communityManager", + createInstance: { dependencies in CommunityManager(using: dependencies) } ) } // MARK: - Log.Category public extension Log.Category { - static let openGroup: Log.Category = .create("OpenGroup", defaultLevel: .info) + static let communityManager: Log.Category = .create("communityManager", defaultLevel: .info) } -// MARK: - OpenGroupManager +// MARK: - CommunityManager -public final class OpenGroupManager { - public typealias DefaultRoomInfo = (room: Network.SOGS.Room, openGroup: OpenGroup) - +public actor CommunityManager: CommunityManagerType { private let dependencies: Dependencies + nonisolated private let syncState: CommunityManagerSyncState + + nonisolated private let _defaultRooms: CurrentValueAsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> = CurrentValueAsyncStream(([], nil)) + private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? + private var _hasFetchedDefaultRooms: Bool = false + private var _hasLoadedCache: Bool = false + private var _servers: [String: Server] = [:] + + nonisolated public var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { + _defaultRooms.stream + } + public var pendingChanges: [PendingChange] = [] + nonisolated public var syncPendingChanges: [CommunityManager.PendingChange] { syncState.pendingChanges } // MARK: - Initialization init(using dependencies: Dependencies) { self.dependencies = dependencies - } + self.syncState = CommunityManagerSyncState(using: dependencies) + } + + // MARK: - Cache + + @available(*, deprecated, message: "Use `getLastSuccessfulCommunityPollTimestamp` instead") + nonisolated public func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval { + if let storedTime: TimeInterval = syncState.lastSuccessfulCommunityPollTimestamp { + return storedTime + } + + guard let lastPoll: Date = syncState.dependencies[defaults: .standard, key: .lastOpen] else { + return 0 + } + + syncState.update(lastSuccessfulCommunityPollTimestamp: .set(to: lastPoll.timeIntervalSince1970)) + return lastPoll.timeIntervalSince1970 + } + + public func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval { + if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { + return storedTime + } + + guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { + return 0 + } + + _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 + return lastPoll.timeIntervalSince1970 + } + + public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async { + dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) + _lastSuccessfulCommunityPollTimestamp = timestamp + } + + public func fetchDefaultRoomsIfNeeded() async { + /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule + /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running + guard await _defaultRooms.getCurrent().rooms.isEmpty else { return } + + RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) + } + + public func loadCacheIfNeeded() async { + guard !_hasLoadedCache else { return } + + let data: (info: [OpenGroup], capabilities: [Capability], members: [GroupMember]) = (try? await dependencies[singleton: .storage] + .readAsync { db in + let openGroups: [OpenGroup] = try OpenGroup.fetchAll(db) + let ids: [String] = openGroups.map { $0.id } + + return ( + openGroups, + try Capability.fetchAll(db), + try GroupMember + .filter(ids.contains(GroupMember.Columns.groupId)) + .fetchAll(db) + ) + }) + .defaulting(to: ([], [], [])) + let rooms: [String: [OpenGroup]] = data.info.grouped(by: \.server) + let capabilities: [String: [Capability.Variant]] = data.capabilities.reduce(into: [:]) { result, next in + result.append(next.variant, toArrayOn: next.openGroupServer.lowercased()) + } + let members: [String: [GroupMember]] = data.members.grouped(by: \.groupId) + + _servers = rooms.reduce(into: [:]) { result, next in + guard + let threadId: String = next.value.first?.threadId, + let publicKey: String = next.value.first?.publicKey + else { return } + + let server: String = next.key.lowercased() + result[server] = CommunityManager.Server( + server: server, + publicKey: publicKey, + openGroups: next.value, + capabilities: capabilities[server].map { Set($0) }, + members: members[threadId], + using: dependencies + ) + } + _hasLoadedCache = true + } + + public func server(_ server: String) async -> Server? { + return _servers[server.lowercased()] + } + + public func updateServer(server: Server) async { + _servers[server.server.lowercased()] = server + } + + public func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async { + switch _servers[server.lowercased()] { + case .none: + _servers[server.lowercased()] = CommunityManager.Server( + server: server.lowercased(), + publicKey: publicKey, + openGroups: [], + capabilities: capabilities, + members: nil, + using: dependencies + ) + + case .some(let existingServer): + _servers[server.lowercased()] = existingServer.with( + capabilities: .set(to: capabilities), + using: dependencies + ) + } + } + + public func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async { + /// For default rooms we don't want to replicate or store them alongside other room data, so just emit that we have received + /// them and stop (since we don't want to poll or interact with these outside of the default rooms UI we want to avoid keeping + /// them alongside other room data) + guard !areDefaultRooms else { + await _defaultRooms.send((rooms, nil)) + return + } + + let targetServer: Server = ( + _servers[server.lowercased()] ?? + CommunityManager.Server( + server: server.lowercased(), + publicKey: publicKey, + openGroups: [], + capabilities: nil, + members: nil, + using: dependencies + ) + ) + _servers[server.lowercased()] = targetServer.with( + rooms: .set(to: rooms), + using: dependencies + ) + } + + public func removeRoom(server: String, roomToken: String) async { + let serverString: String = server.lowercased() + + guard let server: Server = _servers[serverString] else { return } + + _servers[serverString] = server.with( + rooms: .set(to: Array(server.rooms.removingValue(forKey: roomToken).values)), + using: dependencies + ) + } // MARK: - Adding & Removing @@ -66,12 +225,12 @@ public final class OpenGroupManager { return ":\(port)" } - public static func isSessionRunOpenGroup(server: String) -> Bool { + public static func isSessionRunCommunity(server: String) -> Bool { guard let serverUrl: URL = (URL(string: server.lowercased()) ?? URL(string: "http://\(server.lowercased())")) else { return false } - let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) + let serverPort: String = CommunityManager.port(for: server, serverUrl: serverUrl) let serverHost: String = serverUrl.host .defaulting( to: server @@ -88,15 +247,14 @@ public final class OpenGroupManager { return options.contains(serverHost) } - public func hasExistingOpenGroup( - _ db: ObservingDatabase, + nonisolated public func hasExistingCommunity( roomToken: String, server: String, publicKey: String ) -> Bool { guard let serverUrl: URL = URL(string: server.lowercased()) else { return false } - let serverPort: String = OpenGroupManager.port(for: server, serverUrl: serverUrl) + let serverPort: String = CommunityManager.port(for: server, serverUrl: serverUrl) let serverHost: String = serverUrl.host .defaulting( to: server @@ -113,9 +271,8 @@ public final class OpenGroupManager { "https://\(serverHost)\(serverPort)" ]) - // If the server is run by Session then include all configurations in case one of the alternate configurations - // was used - if OpenGroupManager.isSessionRunOpenGroup(server: server) { + /// If the server is run by Session then include all configurations in case one of the alternate configurations was used + if CommunityManager.isSessionRunCommunity(server: server) { serverOptions.insert(defaultServerHost) serverOptions.insert("http://\(defaultServerHost)") serverOptions.insert("https://\(defaultServerHost)") @@ -124,40 +281,30 @@ public final class OpenGroupManager { serverOptions.insert("https://\(Network.SOGS.legacyDefaultServerIP)") } - // First check if there is no poller for the specified server - if Set(dependencies[cache: .communityPollers].serversBeingPolled).intersection(serverOptions).isEmpty { - return false - } + /// Check if the result matches an entry in the cache + let cachedServers: [String: Server] = syncState.servers - // Then check if there is an existing open group thread - let hasExistingThread: Bool = serverOptions.contains(where: { serverName in - (try? SessionThread - .exists( - db, - id: OpenGroup.idFor(roomToken: roomToken, server: serverName) - )) - .defaulting(to: false) - }) - - return hasExistingThread + return serverOptions.contains { serverName in + cachedServers[serverName.lowercased()]?.rooms[roomToken] != nil + } } - public func add( + nonisolated public func add( _ db: ObservingDatabase, roomToken: String, server: String, publicKey: String, forceVisible: Bool ) -> Bool { - // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - if hasExistingOpenGroup(db, roomToken: roomToken, server: server, publicKey: publicKey) { - Log.info(.openGroup, "Ignoring join open group attempt (already joined)") + /// No need to do anything if the community is already in the cache + if hasExistingCommunity(roomToken: roomToken, server: server, publicKey: publicKey) { + Log.info(.communityManager, "Ignoring join open group attempt (already joined)") return false } - // Store the open group information + /// Normalize the server let targetServer: String = { - guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { + guard CommunityManager.isSessionRunCommunity(server: server) else { return server.lowercased() } @@ -165,8 +312,8 @@ public final class OpenGroupManager { }() let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: targetServer) - // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an - // inactive one but that won't matter as we then activate it) + /// Optionally try to insert a new version of the `OpenGroup` (it will fail if there is already an inactive one but that won't matter + /// as we then activate it) _ = try? SessionThread.upsert( db, id: threadId, @@ -176,30 +323,43 @@ public final class OpenGroupManager { /// handling then we want to wait until it actually has messages before making it visible) shouldBeVisible: (forceVisible ? .setTo(true) : .useExisting) ), - using: dependencies + using: syncState.dependencies ) - if (try? OpenGroup.exists(db, id: threadId)) == false { - try? OpenGroup - .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) - .upsert(db) - } + /// Update the state to allow polling and reset the `sequenceNumber` + let openGroup: OpenGroup = OpenGroup + .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) + .with(shouldPoll: true, sequenceNumber: 0) + try? openGroup.upsert(db) - // Set the group to active and reset the sequenceNumber (handle groups which have - // been deactivated) - _ = try? OpenGroup - .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) - .updateAllAndConfig( - db, - OpenGroup.Columns.isActive.set(to: true), - OpenGroup.Columns.sequenceNumber.set(to: 0), - using: dependencies - ) + /// Update the cache to have a record of the new room + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + let targetRooms: [Network.SOGS.Room] + + switch await self?._servers[server.lowercased()] { + case .none: + targetRooms = [Network.SOGS.Room(openGroup: openGroup)] + + case .some(let existingServer): + targetRooms = ( + Array(existingServer.rooms.values) + [Network.SOGS.Room(openGroup: openGroup)] + ) + } + + await self?.updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } + } return true } - public func performInitialRequestsAfterAdd( + nonisolated public func performInitialRequestsAfterAdd( queue: DispatchQueue, successfullyAddedGroup: Bool, roomToken: String, @@ -209,8 +369,8 @@ public final class OpenGroupManager { // Only bother performing the initial request if the network isn't suspended guard successfullyAddedGroup, - !dependencies[singleton: .storage].isSuspended, - !dependencies[cache: .libSessionNetwork].isSuspended + !syncState.dependencies[singleton: .storage].isSuspended, + !syncState.dependencies[cache: .libSessionNetwork].isSuspended else { return Just(()) .setFailureType(to: Error.self) @@ -219,7 +379,7 @@ public final class OpenGroupManager { // Store the open group information let targetServer: String = { - guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { + guard CommunityManager.isSessionRunCommunity(server: server) else { return server.lowercased() } @@ -238,12 +398,14 @@ public final class OpenGroupManager { capabilities: [] /// We won't have `capabilities` before the first request so just hard code ) ), - using: dependencies + using: syncState.dependencies ) } .publisher - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in + .flatMap { [dependencies = syncState.dependencies] in $0.send(using: dependencies) } + .flatMapStorageWritePublisher(using: syncState.dependencies) { [weak self, dependencies = syncState.dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, value: Network.SOGS.CapabilitiesAndRoomResponse)) -> Void in + guard let self = self else { throw StorageError.objectNotSaved } + // Add the new open group to libSession try LibSession.add( db, @@ -254,24 +416,24 @@ public final class OpenGroupManager { ) // Store the capabilities first - OpenGroupManager.handleCapabilities( + handleCapabilities( db, capabilities: response.value.capabilities.data, - on: targetServer + server: targetServer, + publicKey: publicKey ) // Then the room - try OpenGroupManager.handlePollInfo( + try handlePollInfo( db, pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), + roomToken: roomToken, + server: targetServer, publicKey: publicKey, - for: roomToken, - on: targetServer, - using: dependencies ) } .handleEvents( - receiveCompletion: { [dependencies] result in + receiveCompletion: { [dependencies = syncState.dependencies] result in switch result { case .finished: // (Re)start the poller if needed (want to force it to poll immediately in the next @@ -282,14 +444,14 @@ public final class OpenGroupManager { poller.startIfNeeded() } - case .failure(let error): Log.error(.openGroup, "Failed to join open group with error: \(error).") + case .failure(let error): Log.error(.communityManager, "Failed to join open group with error: \(error).") } } ) .eraseToAnyPublisher() } - public func delete( + nonisolated public func delete( _ db: ObservingDatabase, openGroupId: String, skipLibSessionUpdate: Bool @@ -311,14 +473,15 @@ public final class OpenGroupManager { // we don't want to start a poller for this as the user hasn't actually joined a room let numActiveRooms: Int = (try? OpenGroup .filter(OpenGroup.Columns.server == server?.lowercased()) - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) + .filter(OpenGroup.Columns.shouldPoll == true) .fetchCount(db)) .defaulting(to: 1) if numActiveRooms == 1, let server: String = server?.lowercased() { - dependencies.mutate(cache: .communityPollers) { - $0.stopAndRemovePoller(for: server) + db.afterCommit { [weak self] in + self?.syncState.dependencies.mutate(cache: .communityPollers) { + $0.stopAndRemovePoller(for: server) + } } } @@ -331,36 +494,40 @@ public final class OpenGroupManager { db.addConversationEvent(id: openGroupId, type: .deleted) // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) - try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: dependencies) + try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: syncState.dependencies) // Remove the open group (no foreign key to the thread so it won't auto-delete) - if server?.lowercased() != Network.SOGS.defaultServer.lowercased() { - _ = try? OpenGroup - .filter(id: openGroupId) + _ = try? OpenGroup + .filter(id: openGroupId) + .deleteAll(db) + + // Delete any capabilities associated with the room (no foreign key so it won't auto-delete) + if numActiveRooms == 1, let server: String = server { + _ = try? Capability + .filter(Capability.Columns.openGroupServer == server.lowercased()) .deleteAll(db) } - else { - // If it's a session-run room then just set it to inactive - _ = try? OpenGroup - .filter(id: openGroupId) - .updateAllAndConfig( - db, - OpenGroup.Columns.isActive.set(to: false), - using: dependencies - ) - } - if !skipLibSessionUpdate, let server: String = server, let roomToken: String = roomToken { - try LibSession.remove(db, server: server, roomToken: roomToken, using: dependencies) + if let server: String = server, let roomToken: String = roomToken { + if !skipLibSessionUpdate { + try LibSession.remove(db, server: server, roomToken: roomToken, using: syncState.dependencies) + } + + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + await self?.removeRoom(server: server, roomToken: roomToken) + } + } } } // MARK: - Response Processing - internal static func handleCapabilities( + nonisolated public func handleCapabilities( _ db: ObservingDatabase, capabilities: Network.SOGS.CapabilitiesResponse, - on server: String + server: String, + publicKey: String ) { // Remove old capabilities first _ = try? Capability @@ -368,10 +535,12 @@ public final class OpenGroupManager { .deleteAll(db) // Then insert the new capabilities (both present and missing) - capabilities.capabilities.forEach { capability in + let newCapabilities: Set = Set(capabilities.capabilities + .map { Capability.Variant(from: $0) }) + newCapabilities.forEach { variant in try? Capability( openGroupServer: server.lowercased(), - variant: Capability.Variant(from: capability), + variant: variant, isMissing: false ) .upsert(db) @@ -384,15 +553,25 @@ public final class OpenGroupManager { ) .upsert(db) } + + /// Update the `CommunityManager` cache + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + await self?.updateCapabilities( + capabilities: newCapabilities, + server: server, + publicKey: publicKey + ) + } + } } - internal static func handlePollInfo( + nonisolated public func handlePollInfo( _ db: ObservingDatabase, pollInfo: Network.SOGS.RoomPollInfo, - publicKey maybePublicKey: String?, - for roomToken: String, - on server: String, - using dependencies: Dependencies + roomToken: String, + server: String, + publicKey: String ) throws { // Create the open group model and get or create the thread let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) @@ -404,8 +583,8 @@ public final class OpenGroupManager { let hasDetails: Bool = (pollInfo.details != nil) let permissions: OpenGroup.Permissions = OpenGroup.Permissions(roomInfo: pollInfo) let changes: [ConfigColumnAssignment] = [] - .appending(openGroup.publicKey == maybePublicKey ? nil : - maybePublicKey.map { OpenGroup.Columns.publicKey.set(to: $0) } + .appending(openGroup.publicKey == publicKey ? nil : + OpenGroup.Columns.publicKey.set(to: publicKey) ) .appending(openGroup.userCount == pollInfo.activeUsers ? nil : OpenGroup.Columns.userCount.set(to: pollInfo.activeUsers) @@ -428,10 +607,13 @@ public final class OpenGroupManager { try OpenGroup .filter(id: openGroup.id) - .updateAllAndConfig(db, changes, using: dependencies) + .updateAllAndConfig(db, changes, using: syncState.dependencies) // Update the admin/moderator group members if let roomDetails: Network.SOGS.Room = pollInfo.details { + let oldMembers: [GroupMember]? = try? GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .fetchAll(db) _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .deleteAll(db) @@ -479,6 +661,63 @@ public final class OpenGroupManager { isHidden: true ).upsert(db) } + + /// Schedule an event to be sent + let oldAdmins: Set = Set((oldMembers? + .filter { $0.role == .admin && !$0.isHidden } + .map { $0.profileId }) ?? []) + let oldHiddenAdmins: Set = Set((oldMembers? + .filter { $0.role == .admin && $0.isHidden } + .map { $0.profileId }) ?? []) + let oldMods: Set = Set((oldMembers? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId }) ?? []) + let oldHiddenMods: Set = Set((oldMembers? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId }) ?? []) + let newAdmins: Set = Set(roomDetails.admins) + let newHiddenAdmins: Set = Set(roomDetails.hiddenAdmins ?? []) + let newMods: Set = Set(roomDetails.moderators) + let newHiddenMods: Set = Set(roomDetails.hiddenModerators ?? []) + + if + oldAdmins != newAdmins || + oldHiddenAdmins != newHiddenAdmins || + oldMods != newMods || + oldHiddenMods != newHiddenMods + { + db.addCommunityEvent( + id: threadId, + change: .moderatorsAndAdmins( + admins: Array(newAdmins), + hiddenAdmins: Array(newHiddenAdmins), + moderators: Array(newMods), + hiddenModerators: Array(newHiddenMods) + ) + ) + } + + /// Update the `CommunityManager` cache + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { + let targetRooms: [Network.SOGS.Room] + + switch await self?._servers[server.lowercased()] { + case .none: + targetRooms = [roomDetails] + + case .some(let existingServer): + targetRooms = (Array(existingServer.rooms.values) + [roomDetails]) + } + + await self?.updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } + } } /// Schedule the room image download (if we don't have one or it's been updated) @@ -489,7 +728,7 @@ public final class OpenGroupManager { openGroup.imageId != imageId ) { - dependencies[singleton: .jobRunner].add( + syncState.dependencies[singleton: .jobRunner].add( db, job: Job( variant: .displayPictureDownload, @@ -500,7 +739,7 @@ public final class OpenGroupManager { roomToken: openGroup.roomToken, server: openGroup.server ), - timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + timestamp: (syncState.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) ), canStartJob: true @@ -529,20 +768,22 @@ public final class OpenGroupManager { } } - internal static func handleMessages( + nonisolated public func handleMessages( _ db: ObservingDatabase, messages: [Network.SOGS.Message], for roomToken: String, - on server: String, - using dependencies: Dependencies + on server: String ) -> [MessageReceiver.InsertedInteractionInfo?] { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { - Log.error(.openGroup, "Couldn't handle open group messages due to missing group.") + Log.error(.communityManager, "Couldn't handle open group messages due to missing group.") return [] } - // Sorting the messages by server ID before importing them fixes an issue where messages - // that quote older messages can't find those older messages + /// Sorting the messages by server ID before importing them fixes an issue where messages that quote older messages can't + /// find those older messages + let previousMessageCount: Int = ((try? Interaction + .filter(Interaction.Columns.id == openGroup.id) + .fetchCount(db)) ?? 0) let sortedMessages: [Network.SOGS.Message] = messages .filter { $0.deleted != true } .sorted { lhs, rhs in lhs.id < rhs.id } @@ -578,13 +819,13 @@ public final class OpenGroupManager { whisperMods: message.whisperMods, whisperTo: message.whisperTo ), - using: dependencies + using: syncState.dependencies ) try MessageDeduplication.insert( db, processedMessage: processedMessage, ignoreDedupeFiles: false, - using: dependencies + using: syncState.dependencies ) switch processedMessage { @@ -599,7 +840,7 @@ public final class OpenGroupManager { decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, suppressNotifications: false, - using: dependencies + using: syncState.dependencies ) ) largestValidSeqNo = max(largestValidSeqNo, message.seqNo) @@ -615,7 +856,8 @@ public final class OpenGroupManager { MessageError.selfSend: break - default: Log.error(.openGroup, "Couldn't receive open group message due to error: \(error).") + default: + Log.error(.communityManager, "Couldn't receive open group message due to error: \(error).") } } } @@ -627,18 +869,17 @@ public final class OpenGroupManager { db, openGroupId: openGroup.id, message: message, - associatedPendingChanges: dependencies[cache: .openGroupManager].pendingChanges - .filter { - guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { - return false - } - - if case .reaction(let messageId, _, _) = $0.metadata { - return messageId == message.id - } + associatedPendingChanges: syncPendingChanges.filter { + guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else { return false - }, - using: dependencies + } + + if case .reaction(let messageId, _, _) = $0.metadata { + return messageId == message.id + } + return false + }, + using: syncState.dependencies ) try MessageReceiver.handleOpenGroupReactions( @@ -650,7 +891,7 @@ public final class OpenGroupManager { largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } catch { - Log.error(.openGroup, "Couldn't handle open group reactions due to error: \(error).") + Log.error(.communityManager, "Couldn't handle open group reactions due to error: \(error).") } } } @@ -668,6 +909,12 @@ public final class OpenGroupManager { largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0)) } + // If we didn't previously have any messages for this community then we should notify that the + // initial fetch has now been completed + if previousMessageCount == 0 { + db.addCommunityEvent(id: openGroup.id, change: .receivedInitialMessages(sortedMessages)) + } + // Now that we've finished processing all valid message changes we can update the `sequenceNumber` to // the `largestValidSeqNo` value _ = try? OpenGroup @@ -675,25 +922,50 @@ public final class OpenGroupManager { .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) // Update pendingChange cache based on the `largestValidSeqNo` value - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges = $0.pendingChanges - .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + await setPendingChanges( + pendingChanges.filter { + $0.seqNo == nil || ($0.seqNo ?? 0) > largestValidSeqNo + } + ) + + let targetRooms: [Network.SOGS.Room] + + switch await self.server(server) { + case .none: + targetRooms = [Network.SOGS.Room(openGroup: openGroup)] + + case .some(let existingServer): + targetRooms = ( + Array(existingServer.rooms.values) + [Network.SOGS.Room(openGroup: openGroup)] + ) + } + + await updateRooms( + rooms: targetRooms, + server: openGroup.server, + publicKey: openGroup.publicKey, + areDefaultRooms: false + ) + } } return insertedInteractionInfo } - internal static func handleDirectMessages( + nonisolated public func handleDirectMessages( _ db: ObservingDatabase, messages: [Network.SOGS.DirectMessage], fromOutbox: Bool, - on server: String, - using dependencies: Dependencies + on server: String ) -> [MessageReceiver.InsertedInteractionInfo?] { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return [] } guard let openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) else { - Log.error(.openGroup, "Couldn't receive inbox message due to missing group.") + Log.error(.communityManager, "Couldn't receive inbox message due to missing group.") return [] } @@ -716,11 +988,36 @@ public final class OpenGroupManager { .filter(OpenGroup.Columns.server == server.lowercased()) .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) } + + db.afterCommit { [weak self] in + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + if let server: Server = await self.server(server) { + if fromOutbox { + await updateServer( + server: server.with( + outboxLatestMessageId: .set(to: latestMessageId), + using: dependencies + ) + ) + } + else { + await updateServer( + server: server.with( + inboxLatestMessageId: .set(to: latestMessageId), + using: dependencies + ) + ) + } + } + } + } // Process the messages sortedMessages.forEach { message in guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { - Log.error(.openGroup, "Couldn't receive inbox message.") + Log.error(.communityManager, "Couldn't receive inbox message.") return } @@ -734,13 +1031,13 @@ public final class OpenGroupManager { senderId: message.sender, recipientId: message.recipient ), - using: dependencies + using: syncState.dependencies ) try MessageDeduplication.insert( db, processedMessage: processedMessage, ignoreDedupeFiles: false, - using: dependencies + using: syncState.dependencies ) switch processedMessage { @@ -769,7 +1066,7 @@ public final class OpenGroupManager { openGroupServer: server.lowercased(), openGroupPublicKey: openGroup.publicKey, isCheckingForOutbox: fromOutbox, - using: dependencies + using: syncState.dependencies ) }() lookupCache[message.recipient] = lookup @@ -799,7 +1096,7 @@ public final class OpenGroupManager { decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, suppressNotifications: false, - using: dependencies + using: syncState.dependencies ) ) } @@ -815,7 +1112,7 @@ public final class OpenGroupManager { break default: - Log.error(.openGroup, "Couldn't receive inbox message due to error: \(error).") + Log.error(.communityManager, "Couldn't receive inbox message due to error: \(error).") } } } @@ -830,9 +1127,9 @@ public final class OpenGroupManager { id: Int64, in roomToken: String, on server: String, - type: OpenGroupManager.PendingChange.ReactAction - ) -> OpenGroupManager.PendingChange { - let pendingChange = OpenGroupManager.PendingChange( + type: PendingChange.ReactAction + ) async -> PendingChange { + let pendingChange: PendingChange = PendingChange( server: server, room: roomToken, changeType: .reaction, @@ -842,218 +1139,281 @@ public final class OpenGroupManager { action: type ) ) - - dependencies.mutate(cache: .openGroupManager) { - $0.pendingChanges.append(pendingChange) - } + pendingChanges.append(pendingChange) return pendingChange } - public func updatePendingChange(_ pendingChange: OpenGroupManager.PendingChange, seqNo: Int64?) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges[index].seqNo = seqNo - } + public func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async { + self.pendingChanges = pendingChanges + } + + public func updatePendingChange(_ pendingChange: PendingChange, seqNo: Int64?) async { + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges[index].seqNo = seqNo } } - public func removePendingChange(_ pendingChange: OpenGroupManager.PendingChange) { - dependencies.mutate(cache: .openGroupManager) { - if let index = $0.pendingChanges.firstIndex(of: pendingChange) { - $0.pendingChanges.remove(at: index) - } + public func removePendingChange(_ pendingChange: PendingChange) async { + if let index = pendingChanges.firstIndex(of: pendingChange) { + pendingChanges.remove(at: index) } } /// This method specifies if the given capability is supported on a specified Open Group public func doesOpenGroupSupport( - _ db: ObservingDatabase, capability: Capability.Variant, - on server: String? - ) -> Bool { - guard let server: String = server else { return false } + on maybeServer: String? + ) async -> Bool { + guard + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString) + else { return false } - let capabilities: [Capability.Variant] = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == server) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchAll(db)) - .defaulting(to: []) - - return capabilities.contains(capability) + return cachedServer.capabilities.contains(capability) + } + + public func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set { + guard + let roomToken: String = roomToken, + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString), + let room: Network.SOGS.Room = cachedServer.rooms[roomToken] + else { return [] } + + var result: Set = Set(room.admins + room.moderators) + + if includingHidden else { + result.insert(contentsOf: room.hiddenAdmins ?? []) + result.insert(contentsOf: room.hiddenModerators ?? []) + } + + return result } /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group public func isUserModeratorOrAdmin( - _ db: ObservingDatabase, publicKey: String, - for roomToken: String?, - on server: String?, - currentUserSessionIds: Set - ) -> Bool { - guard let roomToken: String = roomToken, let server: String = server else { return false } + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool { + guard + let roomToken: String = roomToken, + let serverString: String = maybeServer, + let cachedServer: Server = await server(serverString), + let room: Network.SOGS.Room = cachedServer.rooms[roomToken] + else { return false } - let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) - let targetRoles: [GroupMember.Role] = [.moderator, .admin] - var possibleKeys: Set = [publicKey] + /// If the `publicKey` belongs to the current user then we should check against any of their pubkey possibilities + let possibleKeys: Set = (cachedServer.currentUserSessionIds.contains(publicKey) ? + cachedServer.currentUserSessionIds : + [publicKey] + ) - /// If the `publicKey` is in `currentUserSessionIds` then we want to use `currentUserSessionIds` to do - /// the lookup - if currentUserSessionIds.contains(publicKey) { - possibleKeys = currentUserSessionIds - - /// Add the users `unblinded` pubkey if we can get it, just for completeness - let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( - .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) - ) - if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { - possibleKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) - } + /// Check if the `publicKey` matches a visible admin or moderator + let isVisibleModOrAdmin: Bool = ( + !possibleKeys.isDisjoint(with: Set(room.admins)) && + !possibleKeys.isDisjoint(with: Set(room.moderators)) + ) + + /// If they are a visible admin/mod, or we don't want to consider hidden admins/mods, then no need to continue + if isVisibleModOrAdmin || !includingHidden { + return isVisibleModOrAdmin } - return GroupMember - .filter(GroupMember.Columns.groupId == groupId) - .filter(possibleKeys.contains(GroupMember.Columns.profileId)) - .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db) + /// Chcek if the `publicKey` is a hidden admin/mod + return ( + !possibleKeys.isDisjoint(with: Set(room.hiddenAdmins ?? [])) && + !possibleKeys.isDisjoint(with: Set(room.hiddenModerators ?? [])) + ) } } -// MARK: - Deprecated Conveneince Functions +// MARK: - SyncState -public extension OpenGroupManager { - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - func doesOpenGroupSupport( - capability: Capability.Variant, - on server: String? - ) -> Bool { - guard let server: String = server else { return false } - - var openGroupSupportsCapability: Bool = false - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( - retrieve: { [weak self] db in - self?.doesOpenGroupSupport(db, capability: capability, on: server) - }, - completion: { result in - switch result { - case .failure: break - case .success(let value): openGroupSupportsCapability = (value == true) - } - semaphore.signal() - } - ) - semaphore.wait() - return openGroupSupportsCapability +private final class CommunityManagerSyncState { + private let lock: NSLock = NSLock() + private let _dependencies: Dependencies + private var _servers: [String: CommunityManager.Server] = [:] + private var _pendingChanges: [CommunityManager.PendingChange] = [] + + @available(*, deprecated, message: "Remove this alongside 'getLastSuccessfulCommunityPollTimestampSync'") + private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? = nil + + fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } + fileprivate var servers: [String: CommunityManager.Server] { lock.withLock { _servers } } + fileprivate var pendingChanges: [CommunityManager.PendingChange] { lock.withLock { _pendingChanges } } + fileprivate var lastSuccessfulCommunityPollTimestamp: TimeInterval? { + lock.withLock { _lastSuccessfulCommunityPollTimestamp } + } + + fileprivate init(using dependencies: Dependencies) { + self._dependencies = dependencies } - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") + fileprivate func update( + servers: Update<[String: CommunityManager.Server]> = .useExisting, + pendingChanges: Update<[CommunityManager.PendingChange]> = .useExisting, + lastSuccessfulCommunityPollTimestamp: Update = .useExisting, + ) { + lock.withLock { + self._servers = servers.or(self._servers) + self._pendingChanges = pendingChanges.or(self._pendingChanges) + self._lastSuccessfulCommunityPollTimestamp = lastSuccessfulCommunityPollTimestamp + .or(self._lastSuccessfulCommunityPollTimestamp) + } + } +} + +// MARK: - CommunityManagerType + +public protocol CommunityManagerType { + nonisolated var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { get } + var pendingChanges: [CommunityManager.PendingChange] { get async } + nonisolated var syncPendingChanges: [CommunityManager.PendingChange] { get } + + // MARK: - Cache + + nonisolated func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval + func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval + func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async + + func fetchDefaultRoomsIfNeeded() async + func loadCacheIfNeeded() async + + func server(_ server: String) async -> CommunityManager.Server? + func updateServer(server: CommunityManager.Server) async + func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async + func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async + + // MARK: - Adding & Removing + + func hasExistingCommunity(roomToken: String, server: String, publicKey: String) async -> Bool + + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + forceVisible: Bool + ) -> Bool + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws + + // MARK: - Response Processing + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + server: String, + publicKey: String + ) + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + roomToken: String, + server: String, + publicKey: String + ) throws + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + for roomToken: String, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + on server: String + ) -> [MessageReceiver.InsertedInteractionInfo?] + + // MARK: - Convenience + + func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: CommunityManager.PendingChange.ReactAction + ) async -> CommunityManager.PendingChange + func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async + func updatePendingChange(_ pendingChange: CommunityManager.PendingChange, seqNo: Int64?) async + func removePendingChange(_ pendingChange: CommunityManager.PendingChange) async + + func doesOpenGroupSupport( + capability: Capability.Variant, + on maybeServer: String? + ) async -> Bool + func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set func isUserModeratorOrAdmin( publicKey: String, - for roomToken: String?, - on server: String?, - currentUserSessionIds: Set - ) -> Bool { - guard let roomToken: String = roomToken, let server: String = server else { return false } - - var userIsModeratorOrAdmin: Bool = false - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - dependencies[singleton: .storage].readAsync( - retrieve: { [weak self] db in - self?.isUserModeratorOrAdmin( - db, - publicKey: publicKey, - for: roomToken, - on: server, - currentUserSessionIds: currentUserSessionIds - ) - }, - completion: { result in - switch result { - case .failure: break - case .success(let value): userIsModeratorOrAdmin = (value == true) - } - semaphore.signal() - } - ) - semaphore.wait() - return userIsModeratorOrAdmin - } + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool } -// MARK: - OpenGroupManager Cache +// MARK: - Observations -public extension OpenGroupManager { - class Cache: OGMCacheType { - private let dependencies: Dependencies - private let defaultRoomsSubject: CurrentValueSubject<[DefaultRoomInfo], Error> = CurrentValueSubject([]) - private var _lastSuccessfulCommunityPollTimestamp: TimeInterval? - public var pendingChanges: [OpenGroupManager.PendingChange] = [] - - public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error> { - defaultRoomsSubject - .handleEvents( - receiveSubscription: { [weak defaultRoomsSubject, dependencies] _ in - /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule - /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running - if defaultRoomsSubject?.value.isEmpty == true { - RetrieveDefaultOpenGroupRoomsJob.run(using: dependencies) - } - } - ) - .filter { !$0.isEmpty } - .eraseToAnyPublisher() - } - - // MARK: - Initialization - - init(using dependencies: Dependencies) { - self.dependencies = dependencies - } - - // MARK: - Functions - - public func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - if let storedTime: TimeInterval = _lastSuccessfulCommunityPollTimestamp { - return storedTime - } - - guard let lastPoll: Date = dependencies[defaults: .standard, key: .lastOpen] else { - return 0 - } - - _lastSuccessfulCommunityPollTimestamp = lastPoll.timeIntervalSince1970 - return lastPoll.timeIntervalSince1970 - } - - public func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - dependencies[defaults: .standard, key: .lastOpen] = Date(timeIntervalSince1970: timestamp) - _lastSuccessfulCommunityPollTimestamp = timestamp - } - - public func setDefaultRoomInfo(_ info: [DefaultRoomInfo]) { - defaultRoomsSubject.send(info) - } +// stringlint:ignore_contents +public extension ObservableKey { + static func communityUpdated(_ id: String) -> ObservableKey { + ObservableKey("communityUpdated-\(id)", .communityUpdated) } } -// MARK: - OGMCacheType +// stringlint:ignore_contents +public extension GenericObservableKey { + static let communityUpdated: GenericObservableKey = "communityUpdated" +} + +// MARK: - Event Payloads - Conversations -/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way -public protocol OGMImmutableCacheType: ImmutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } +public struct CommunityEvent: Hashable { + public let id: String + public let change: Change - var pendingChanges: [OpenGroupManager.PendingChange] { get } + public enum Change: Hashable { + case receivedInitialMessages([Network.SOGS.Message]) + case capabilities([Capability.Variant]) + case permissions(read: Bool, write: Bool, upload: Bool) + case role(moderator: Bool, admin: Bool, hiddenModerator: Bool, hiddenAdmin: Bool) + case moderatorsAndAdmins(admins: [String], hiddenAdmins: [String], moderators: [String], hiddenModerators: [String]) + } } -public protocol OGMCacheType: OGMImmutableCacheType, MutableCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { get } - - var pendingChanges: [OpenGroupManager.PendingChange] { get set } - - func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval - func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) +public extension ObservingDatabase { + func addCommunityEvent(id: String, change: CommunityEvent.Change) { + let event: CommunityEvent = CommunityEvent(id: id, change: change) + addEvent(ObservedEvent(key: .communityUpdated(id), value: event)) + } } diff --git a/SessionMessagingKit/Open Groups/Types/PendingChange.swift b/SessionMessagingKit/Open Groups/Types/PendingChange.swift index 6ab6cd2145..26a1b35b02 100644 --- a/SessionMessagingKit/Open Groups/Types/PendingChange.swift +++ b/SessionMessagingKit/Open Groups/Types/PendingChange.swift @@ -2,7 +2,7 @@ import Foundation -extension OpenGroupManager { +extension CommunityManager { public struct PendingChange: Equatable { public enum ChangeType { case reaction @@ -24,7 +24,7 @@ extension OpenGroupManager { var seqNo: Int64? let metadata: Metadata - public static func == (lhs: OpenGroupManager.PendingChange, rhs: OpenGroupManager.PendingChange) -> Bool { + public static func == (lhs: CommunityManager.PendingChange, rhs: CommunityManager.PendingChange) -> Bool { guard lhs.server == rhs.server && lhs.room == rhs.room && diff --git a/SessionMessagingKit/Open Groups/Types/Server.swift b/SessionMessagingKit/Open Groups/Types/Server.swift new file mode 100644 index 0000000000..cbd203c65a --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/Server.swift @@ -0,0 +1,200 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionNetworkingKit +import SessionUtilitiesKit + +extension CommunityManager { + /// The `Server` type is an in-memory store of the current state of all rooms the user is subscribed to on a SOGS + public struct Server: Codable, Equatable { + public let server: String + public let publicKey: String + public let capabilities: Set + public let pollFailureCount: Int64 + public let currentUserSessionIds: Set + + public let inboxLatestMessageId: Int64 + public let outboxLatestMessageId: Int64 + + public let rooms: [String: Network.SOGS.Room] + + fileprivate init( + server: String, + publicKey: String, + capabilities: Set, + pollFailureCount: Int64, + currentUserSessionIds: Set, + inboxLatestMessageId: Int64, + outboxLatestMessageId: Int64, + rooms: [String: Network.SOGS.Room] + ) { + self.server = server.lowercased() + self.publicKey = publicKey + self.capabilities = capabilities + self.pollFailureCount = pollFailureCount + self.currentUserSessionIds = currentUserSessionIds + self.inboxLatestMessageId = inboxLatestMessageId + self.outboxLatestMessageId = outboxLatestMessageId + self.rooms = rooms + } + } +} + +// MARK: - Convenience + +public extension CommunityManager.Server { + init( + server: String, + publicKey: String, + openGroups: [OpenGroup] = [], + capabilities: Set? = nil, + members: [GroupMember]? = nil, + using dependencies: Dependencies + ) { + let currentUserSessionIds: Set = CommunityManager.Server.generateCurrentUserSessionIds( + publicKey: publicKey, + capabilities: (capabilities ?? []), + using: dependencies + ) + + self.server = server.lowercased() + self.publicKey = publicKey + self.capabilities = (capabilities ?? []) + self.pollFailureCount = (openGroups.map { $0.pollFailureCount }.max() ?? 0) + self.currentUserSessionIds = currentUserSessionIds + + self.inboxLatestMessageId = (openGroups.map { $0.inboxLatestMessageId }.max() ?? 0) + self.outboxLatestMessageId = (openGroups.map { $0.outboxLatestMessageId }.max() ?? 0) + + self.rooms = openGroups.reduce(into: [:]) { result, next in + result[next.roomToken] = Network.SOGS.Room( + openGroup: next, + members: members, + currentUserSessionIds: currentUserSessionIds + ) + } + } + + func with( + capabilities: Update> = .useExisting, + inboxLatestMessageId: Update = .useExisting, + outboxLatestMessageId: Update = .useExisting, + rooms: Update<[Network.SOGS.Room]> = .useExisting, + using dependencies: Dependencies + ) -> CommunityManager.Server { + let targetCapabilities: Set = capabilities.or(self.capabilities) + + return CommunityManager.Server( + server: server, + publicKey: publicKey, + capabilities: targetCapabilities, + pollFailureCount: pollFailureCount, + currentUserSessionIds: CommunityManager.Server.generateCurrentUserSessionIds( + publicKey: publicKey, + capabilities: targetCapabilities, + using: dependencies + ), + inboxLatestMessageId: inboxLatestMessageId.or(self.inboxLatestMessageId), + outboxLatestMessageId: outboxLatestMessageId.or(self.outboxLatestMessageId), + rooms: { + switch rooms { + case .useExisting: return self.rooms + case .set(let updatedRooms): + return updatedRooms.reduce(into: [:]) { result, next in + result[next.token] = next + } + } + }() + ) + } + + fileprivate static func generateCurrentUserSessionIds( + publicKey: String, + capabilities: Set, + using dependencies: Dependencies + ) -> Set { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + /// If the SOGS explicitly **is not** blinded then don't bother generating the blinded ids + guard capabilities.isEmpty || capabilities.contains(.blind) else { + return [userSessionId.hexString] + } + + let ed25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey + let userBlinded15SessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.blinded15KeyPair(serverPublicKey: publicKey, ed25519SecretKey: ed25519SecretKey)) + .map { SessionId(.blinded15, publicKey: $0.publicKey) } + let userBlinded25SessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.blinded25KeyPair(serverPublicKey: publicKey, ed25519SecretKey: ed25519SecretKey)) + .map { SessionId(.blinded25, publicKey: $0.publicKey) } + + /// Add the users `unblinded` pubkey if we can get it, just for completeness + let userUnblindedSessionId: SessionId? = dependencies[singleton: .crypto] + .generate(.ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed)) + .map { SessionId(.unblinded, publicKey: $0.publicKey) } + + return Set([ + userSessionId.hexString, + userBlinded15SessionId?.hexString, + userBlinded25SessionId?.hexString, + userUnblindedSessionId?.hexString + ].compactMap { $0 }) + } +} + +// MARK: - Convenience + +internal extension Network.SOGS.Room { + init( + openGroup: OpenGroup, + members: [GroupMember]? = nil, + currentUserSessionIds: Set = [] + ) { + let admins: [String] = (members? + .filter { $0.role == .admin && !$0.isHidden } + .map { $0.profileId } ?? []) + let hiddenAdmins: [String]? = members? + .filter { $0.role == .admin && $0.isHidden } + .map { $0.profileId } + let moderators: [String] = (members? + .filter { $0.role == .moderator && !$0.isHidden } + .map { $0.profileId } ?? []) + let hiddenModerators: [String]? = members? + .filter { $0.role == .moderator && $0.isHidden } + .map { $0.profileId } + + self = Network.SOGS.Room( + token: openGroup.roomToken, + name: openGroup.name, + roomDescription: openGroup.description, + infoUpdates: openGroup.infoUpdates, + messageSequence: openGroup.sequenceNumber, + created: 0, /// Updated on first poll + activeUsers: openGroup.userCount, + activeUsersCutoff: 0, /// Updated on first poll + imageId: openGroup.imageId, + pinnedMessages: nil, /// Updated on first poll + admin: ( + !Set(admins).isDisjoint(with: currentUserSessionIds) || + !Set(hiddenAdmins ?? []).isDisjoint(with: currentUserSessionIds) + ), + globalAdmin: false, /// Updated on first poll + admins: admins, /// Updated on first poll + hiddenAdmins: hiddenAdmins, + moderator: ( + !Set(moderators).isDisjoint(with: currentUserSessionIds) || + !Set(hiddenModerators ?? []).isDisjoint(with: currentUserSessionIds) + ), + globalModerator: false, /// Updated on first poll + moderators: moderators, + hiddenModerators: hiddenModerators, + read: (openGroup.permissions?.contains(.read) == true), + defaultRead: false, /// Updated on first poll + defaultAccessible: false, /// Updated on first poll + write: (openGroup.permissions?.contains(.write) == true), + defaultWrite: false, /// Updated on first poll + upload: (openGroup.permissions?.contains(.upload) == true), + defaultUpload: false /// Updated on first poll + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift index 7d28a3df98..b6f7d1cff6 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift @@ -3,7 +3,7 @@ import Foundation import SessionUIKit -public struct LinkPreviewDraft: Equatable, Hashable { +public struct LinkPreviewDraft: Sendable, Equatable, Hashable { public var urlString: String public var title: String? public var imageSource: ImageDataManager.DataSource? diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 6d691e2c42..e254f5a557 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -107,14 +107,19 @@ extension MessageReceiver { .filter(Interaction.Columns.threadId == blindedIdLookup.blindedId) .updateAll(db, Interaction.Columns.threadId.set(to: unblindedThread.id)) - _ = try SessionThread - .deleteOrLeave( - db, - type: .deleteContactConversationAndContact, // Blinded contact isn't synced anyway - threadId: blindedIdLookup.blindedId, - threadVariant: .contact, - using: dependencies - ) + _ = try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, // Blinded contact isn't synced anyway + threadId: blindedIdLookup.blindedId, + threadVariant: .contact, + using: dependencies + ) + + // Notify about unblinding event + db.addContactEvent( + id: blindedIdLookup.blindedId, + change: .unblinded(blindedId: blindedIdLookup.blindedId, unblindedId: senderId) + ) } // Update the `didApproveMe` state of the sender diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index ed1fe48885..f4c3618405 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -221,8 +221,9 @@ extension MessageReceiver { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: guard variant == .standardOutgoing, - let existingInteractionId: Int64 = try? thread.interactions + let existingInteractionId: Int64 = try? Interaction .select(.id) + .filter(Interaction.Columns.threadId == thread.id) .filter(Interaction.Columns.timestampMs == decodedMessage.sentTimestampMs) .filter(Interaction.Columns.variant == variant) .filter(Interaction.Columns.authorId == decodedMessage.sender.hexString) @@ -550,7 +551,7 @@ extension MessageReceiver { // requiring main-thread execution let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] let userSessionId: SessionId = dependencies[cache: .general].sessionId - _ = try Reaction( + try Reaction( interactionId: interactionId, serverHash: message.serverHash, timestampMs: Int64(decodedMessage.sentTimestampMs), @@ -558,7 +559,15 @@ extension MessageReceiver { emoji: vmReaction.emoji, count: 1, sortId: sortId - ).inserted(db) + ).insert(db) + + // Notify of reaction event + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: interactionId, + change: .added(vmReaction.emoji) + ) + let timestampAlreadyRead: Bool = dependencies.mutate(cache: .libSession) { cache in cache.timestampAlreadyRead( threadId: thread.id, @@ -618,11 +627,27 @@ extension MessageReceiver { } case .remove: + let rowIds: [Int64] = try Reaction + .select(Column.rowID) + .filter(Reaction.Columns.interactionId == interactionId) + .filter(Reaction.Columns.authorId == decodedMessage.sender.hexString) + .filter(Reaction.Columns.emoji == vmReaction.emoji) + .asRequest(of: Int64.self) + .fetchAll(db) try Reaction .filter(Reaction.Columns.interactionId == interactionId) .filter(Reaction.Columns.authorId == decodedMessage.sender.hexString) .filter(Reaction.Columns.emoji == vmReaction.emoji) .deleteAll(db) + + // Notify of reaction event + rowIds.forEach { + db.addReactionEvent( + id: $0, + messageId: interactionId, + change: .removed(vmReaction.emoji) + ) + } } return interactionId diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index e5fccab36d..39667825df 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -410,28 +410,54 @@ public enum MessageReceiver { openGroupMessageServerId: Int64, openGroupReactions: [Reaction] ) throws { - struct Info: Decodable, FetchableRecord { + struct InteractionInfo: Decodable, FetchableRecord { let id: Int64 let variant: Interaction.Variant } + struct ReactionInfo: Decodable, FetchableRecord { + let rowID: Int64 + let emoji: String + } - guard let interactionInfo: Info = try? Interaction + guard let interactionInfo: InteractionInfo = try? Interaction .select(.id, .variant) .filter(Interaction.Columns.threadId == threadId) .filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId) - .asRequest(of: Info.self) + .asRequest(of: InteractionInfo.self) .fetchOne(db) else { throw MessageError.invalidMessage("Could not find message reaction is associated to") } // If the user locally deleted the message then we don't want to process reactions for it guard !interactionInfo.variant.isDeletedMessage else { return } + let removedReactions: [ReactionInfo] = try Reaction + .select(Column.rowID, Reaction.Columns.emoji) + .filter(Reaction.Columns.interactionId == interactionInfo.id) + .asRequest(of: ReactionInfo.self) + .fetchAll(db) + _ = try Reaction .filter(Reaction.Columns.interactionId == interactionInfo.id) .deleteAll(db) + // Send events + removedReactions.forEach { reaction in + db.addReactionEvent( + id: reaction.rowID, + messageId: interactionInfo.id, + change: .removed(reaction.emoji) + ) + } + for reaction in openGroupReactions { try reaction.with(interactionId: interactionInfo.id).insert(db) + + // Send event + db.addReactionEvent( + id: db.lastInsertedRowID, + messageId: interactionInfo.id, + change: .added(reaction.emoji) + ) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 0e092218f7..29b5124808 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -424,15 +424,19 @@ extension MessageSender { public extension VisibleMessage { static func from(_ db: ObservingDatabase, interaction: Interaction) -> VisibleMessage { - let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) + let linkPreview: LinkPreview? = try? Interaction + .linkPreview(url: interaction.linkPreviewUrl, timestampMs: interaction.timestampMs)? + .fetchOne(db) + let attachments: [Attachment]? = try? Interaction + .attachments(interactionId: interaction.id)? + .fetchAll(db) let visibleMessage: VisibleMessage = VisibleMessage( sender: interaction.authorId, sentTimestampMs: UInt64(interaction.timestampMs), syncTarget: nil, text: interaction.body, - attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) - .map { $0.id }, + attachmentIds: (attachments ?? []).map { $0.id }, quote: (try? Quote .filter(Quote.Columns.interactionId == interaction.id) .fetchOne(db)) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index e322563198..8a6a3b2254 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -162,7 +162,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let roomIds: Set = try OpenGroup .filter( OpenGroup.Columns.server == pollerDestination.target && - OpenGroup.Columns.isActive == true + OpenGroup.Columns.shouldPoll == true ) .select(.roomToken) .asRequest(of: String.self) @@ -180,7 +180,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { .fetchSet(db) try hiddenRoomIds.forEach { id in - try dependencies[singleton: .openGroupManager].delete( + try dependencies[singleton: .communityManager].delete( db, openGroupId: id, /// **Note:** We pass `skipLibSessionUpdate` as `true` @@ -223,17 +223,27 @@ public final class CommunityPoller: CommunityPollerType & PollerType { .subscribe(on: pollerQueue, using: dependencies) .receive(on: pollerQueue, using: dependencies) .tryMap { [dependencies] authMethod in - try Network.SOGS.preparedCapabilities( - authMethod: authMethod, - using: dependencies + ( + authMethod, + try Network.SOGS.preparedCapabilities( + authMethod: authMethod, + using: dependencies + ) ) } - .flatMap { [dependencies] in $0.send(using: dependencies) } - .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination] (db: ObservingDatabase, response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesResponse)) in - OpenGroupManager.handleCapabilities( + .flatMap { [dependencies] authMethod, request in + request.send(using: dependencies).map { ($0.0, $0.1, authMethod) } + } + .flatMapStorageWritePublisher(using: dependencies) { [pollerDestination, dependencies] (db: ObservingDatabase, response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesResponse, authMethod: AuthenticationMethod)) in + guard case .community(_, let publicKey, _, _, _) = response.authMethod.info else { + throw CryptoError.invalidAuthentication + } + + dependencies[singleton: .communityManager].handleCapabilities( db, capabilities: response.data, - on: pollerDestination.target + server: pollerDestination.target, + publicKey: publicKey ) } .tryCatch { try handleError($0) } @@ -273,9 +283,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { ) let lastSuccessfulPollTimestamp: TimeInterval = (self.lastPollStart > 0 ? lastPollStart : - dependencies.mutate(cache: .openGroupManager) { cache in - cache.getLastSuccessfulCommunityPollTimestamp() - } + dependencies[singleton: .communityManager].getLastSuccessfulCommunityPollTimestampSync() ) return dependencies[singleton: .storage] @@ -285,8 +293,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let roomInfo: [Network.SOGS.PollRoomInfo] = try OpenGroup .select(.roomToken, .infoUpdates, .sequenceNumber) .filter(OpenGroup.Columns.server == server) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.shouldPoll == true) .asRequest(of: Network.SOGS.PollRoomInfo.self) .fetchAll(db) @@ -309,7 +316,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { try Authentication.with(db, server: server, using: dependencies) ) } - .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap), Error> in + .tryFlatMap { [pollCount, dependencies] pollInfo -> AnyPublisher<(ResponseInfoType, Network.BatchResponseMap, AuthenticationMethod), Error> in try Network.SOGS .preparedPoll( roomInfo: pollInfo.roomInfo, @@ -324,11 +331,18 @@ public final class CommunityPoller: CommunityPollerType & PollerType { using: dependencies ) .send(using: dependencies) + .map { ($0.0, $0.1, pollInfo.authMethod) } + .eraseToAnyPublisher() } - .flatMapOptional { [weak self, failureCount, dependencies] info, response in - self?.handlePollResponse( + .tryFlatMapOptional { [weak self, failureCount, dependencies] info, response, authMethod in + guard case .community(_, let publicKey, _, _, _) = authMethod.info else { + throw CryptoError.invalidAuthentication + } + + return self?.handlePollResponse( info: info, response: response, + publicKey: publicKey, failureCount: failureCount, using: dependencies ) @@ -340,10 +354,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { Task { [weak self] in await self?.pollCountStream.send(updatedPollCount) - } - - dependencies.mutate(cache: .openGroupManager) { cache in - cache.setLastSuccessfulCommunityPollTimestamp( + await dependencies[singleton: .communityManager].setLastSuccessfulCommunityPollTimestamp( dependencies.dateNow.timeIntervalSince1970 ) } @@ -355,6 +366,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { private func handlePollResponse( info: ResponseInfoType, response: Network.BatchResponseMap, + publicKey: String, failureCount: Int, using dependencies: Dependencies ) -> AnyPublisher { @@ -525,10 +537,11 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: Network.SOGS.CapabilitiesResponse = responseData.body else { return } - OpenGroupManager.handleCapabilities( + dependencies[singleton: .communityManager].handleCapabilities( db, capabilities: responseBody, - on: pollerDestination.target + server: pollerDestination.target, + publicKey: publicKey ) case .roomPollInfo(let roomToken, _): @@ -537,13 +550,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: Network.SOGS.RoomPollInfo = responseData.body else { return } - try OpenGroupManager.handlePollInfo( + try dependencies[singleton: .communityManager].handlePollInfo( db, pollInfo: responseBody, - publicKey: nil, - for: roomToken, - on: pollerDestination.target, - using: dependencies + roomToken: roomToken, + server: pollerDestination.target, + publicKey: publicKey ) case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): @@ -553,12 +565,11 @@ public final class CommunityPoller: CommunityPollerType & PollerType { else { return } interactionInfo.append( - contentsOf: OpenGroupManager.handleMessages( + contentsOf: dependencies[singleton: .communityManager].handleMessages( db, messages: responseBody.compactMap { $0.value }, for: roomToken, - on: pollerDestination.target, - using: dependencies + on: pollerDestination.target ) ) @@ -578,12 +589,11 @@ public final class CommunityPoller: CommunityPollerType & PollerType { }() interactionInfo.append( - contentsOf: OpenGroupManager.handleDirectMessages( + contentsOf: dependencies[singleton: .communityManager].handleDirectMessages( db, messages: messages, fromOutbox: fromOutbox, - on: pollerDestination.target, - using: dependencies + on: pollerDestination.target ) ) @@ -671,8 +681,7 @@ public extension CommunityPoller { OpenGroup.Columns.server, max(OpenGroup.Columns.pollFailureCount).forKey(Info.Columns.pollFailureCount) ) - .filter(OpenGroup.Columns.isActive == true) - .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.shouldPoll == true) .group(OpenGroup.Columns.server) .asRequest(of: Info.self) .fetchAll(db) diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index b7b8b8adfc..92af17983f 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -3,49 +3,61 @@ import Foundation import GRDB -public struct QuotedReplyModel { +public struct QuotedReplyModel: Sendable, Equatable, Hashable { public let threadId: String + public let quotedInteractionId: Int64? public let authorId: String + public let authorName: String public let timestampMs: Int64 public let body: String? public let attachment: Attachment? public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool + public let proFeatures: SessionPro.Features public let currentUserSessionIds: Set // MARK: - Initialization - init( + private init( threadId: String, + quotedInteractionId: Int64?, authorId: String, + authorName: String, timestampMs: Int64, body: String?, attachment: Attachment?, contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool, + proFeatures: SessionPro.Features, currentUserSessionIds: Set ) { - self.attachment = attachment self.threadId = threadId + self.quotedInteractionId = quotedInteractionId self.authorId = authorId + self.authorName = authorName self.timestampMs = timestampMs self.body = body + self.attachment = attachment self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed + self.proFeatures = proFeatures self.currentUserSessionIds = currentUserSessionIds } public static func quotedReplyForSending( threadId: String, + quotedInteractionId: Int64?, authorId: String, + authorName: String, variant: Interaction.Variant, body: String?, timestampMs: Int64, attachments: [Attachment]?, linkPreviewAttachment: Attachment?, + proFeatures: SessionPro.Features, currentUserSessionIds: Set ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } @@ -55,13 +67,16 @@ public struct QuotedReplyModel { return QuotedReplyModel( threadId: threadId, + quotedInteractionId: quotedInteractionId, authorId: authorId, + authorName: authorName, timestampMs: timestampMs, body: body, attachment: targetAttachment, contentType: targetAttachment?.contentType, sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, + proFeatures: proFeatures, currentUserSessionIds: currentUserSessionIds ) } diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 3486db29e6..ca590e5331 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -82,6 +82,10 @@ public actor TypingIndicators { } } + public func isRecipientTyping(threadId: String) async -> Bool { + return (self.incoming[threadId] != nil) + } + fileprivate func handleRefresh(threadId: String, threadVariant: SessionThread.Variant) async { try? await dependencies[singleton: .storage].writeAsync { db in try? MessageSender.send( @@ -135,10 +139,13 @@ public extension TypingIndicators { switch direction { case .outgoing: scheduleRefreshCallback(using: dependencies) case .incoming: - try? await dependencies[singleton: .storage].writeAsync { [threadId, initialTimestampMs] db in - try ThreadTypingIndicator(threadId: threadId, timestampMs: initialTimestampMs).upsert(db) - db.addTypingIndicatorEvent(threadId: threadId, change: .started) - } + await dependencies.notify( + key: .typingIndicator(threadId), + value: TypingIndicatorEvent( + threadId: threadId, + change: .started + ) + ) } await refreshTimeout(sentTimestampMs: initialTimestampMs, using: dependencies) @@ -149,9 +156,9 @@ public extension TypingIndicators { /// `refreshTask` (and if one of those triggered this call then the code would otherwise stop executing because the /// parent task is cancelled Task.detached { [threadId, threadVariant, direction, storage = dependencies[singleton: .storage]] in - try? await storage.writeAsync { db in - switch direction { - case .outgoing: + switch direction { + case .outgoing: + try? await storage.writeAsync { db in try MessageSender.send( db, message: TypingIndicator(kind: .stopped), @@ -160,13 +167,16 @@ public extension TypingIndicators { threadVariant: threadVariant, using: dependencies ) - - case .incoming: - _ = try ThreadTypingIndicator - .filter(ThreadTypingIndicator.Columns.threadId == threadId) - .deleteAll(db) - db.addTypingIndicatorEvent(threadId: threadId, change: .stopped) - } + } + + case .incoming: + await dependencies.notify( + key: .typingIndicator(threadId), + value: TypingIndicatorEvent( + threadId: threadId, + change: .stopped + ) + ) } } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 00f309e850..0019ac35b0 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -37,6 +37,9 @@ public actor SessionProManager: SessionProManagerType { nonisolated private let decodedProForMessageStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } + nonisolated public var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { + syncState.backendUserProStatus + } nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.backendUserProStatus == .active } nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } nonisolated public var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { @@ -162,10 +165,15 @@ public actor SessionProManager: SessionProManagerType { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { - guard - syncState.dependencies[feature: .sessionProEnabled], - syncState.backendUserProStatus != .active - else { return false } + guard syncState.dependencies[feature: .sessionProEnabled] else { return false } + + switch variant { + case .groupLimit: break /// The `groupLimit` CTA can be shown for Session Pro users as well + default: + guard syncState.backendUserProStatus != .active else { return false } + + break + } beforePresented?() let sessionProModal: ModalHostingViewController = ModalHostingViewController( @@ -276,6 +284,7 @@ public protocol SessionProManagerType: SessionProUIManagerType { nonisolated var characterLimit: Int { get } nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } + nonisolated var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } nonisolated var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { get } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index ca4a74491c..4fcda12730 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -118,7 +118,8 @@ public extension MessageViewModel { public extension MessageViewModel.DeletionBehaviours { static func deletionActions( for cellViewModels: [MessageViewModel], - with threadData: SessionThreadViewModel, + threadData: SessionThreadViewModel, + isUserModeratorOrAdmin: Bool, using dependencies: Dependencies ) -> MessageViewModel.DeletionBehaviours? { enum SelectedMessageState { @@ -155,19 +156,7 @@ public extension MessageViewModel.DeletionBehaviours { switch threadData.threadVariant { case .contact: return false case .group, .legacyGroup: return (threadData.currentUserIsClosedGroupAdmin == true) - case .community: - guard - let server: String = threadData.openGroupServer, - let roomToken: String = threadData.openGroupRoomToken - else { return false } - - return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( - db, - publicKey: threadData.currentUserSessionId, - for: roomToken, - on: server, - currentUserSessionIds: (threadData.currentUserSessionIds ?? []) - ) + case .community: return isUserModeratorOrAdmin } }() @@ -380,7 +369,7 @@ public extension MessageViewModel.DeletionBehaviours { .filter { threadData.currentUserSessionId.contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( @@ -526,7 +515,7 @@ public extension MessageViewModel.DeletionBehaviours { .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) return [.cancelPendingSendJobs(targetViewModels.map { $0.id })] @@ -583,7 +572,7 @@ public extension MessageViewModel.DeletionBehaviours { /// Only try to delete messages with server hashes (can't delete them otherwise) let serverHashes: Set = cellViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(cellViewModels.flatMap { message in - (message.reactionInfo ?? []).compactMap { $0.reaction.serverHash } + message.reactionInfo.compactMap { $0.reaction.serverHash } })) return [.cancelPendingSendJobs(cellViewModels.map { $0.id })] diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 71d534d69e..5eadc1325b 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -8,93 +8,8 @@ import DifferenceKit import SessionUIKit import SessionUtilitiesKit -fileprivate typealias ViewModel = MessageViewModel -fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo -fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo -fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo -fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo - -public struct QuoteViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { - fileprivate static let numberOfColumns: Int = 4 - - public let interactionId: Int64 - public let authorId: String - public let timestampMs: Int64 - public let body: String? - - public init(interactionId: Int64, authorId: String, timestampMs: Int64, body: String?) { - self.interactionId = interactionId - self.authorId = authorId - self.timestampMs = timestampMs - self.body = body - } -} - -// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) -public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case threadId - case threadVariant - case threadIsTrusted - case threadExpirationType - case threadExpirationTimer - case threadOpenGroupServer - case threadOpenGroupPublicKey - case threadContactNameInternal - - // Interaction Info - - case rowId - case id - case serverHash - case openGroupServerMessageId - case variant - case timestampMs - case receivedAtTimestampMs - case authorId - case authorNameInternal - case body - case rawBody - case expiresStartedAtMs - case expiresInSeconds - case isProMessage - - case state - case hasBeenReadByRecipient - case mostRecentFailureText - case isSenderModeratorOrAdmin - case isTypingIndicator - case profile - case quotedInfo - case linkPreview - case linkPreviewAttachment - - case currentUserSessionId - - // Post-Query Processing Data - - case attachments - case reactionInfo - case cellType - case authorName - case authorNameSuppressedId - case senderName - case canHaveProfile - case shouldShowProfile - case shouldShowDateHeader - case containsOnlyEmoji - case glyphCount - case previousVariant - case positionInCluster - case isOnlyMessageInCluster - case isLast - case isLastOutgoing - case currentUserSessionIds - case optimisticMessageId - } - - public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { +public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Differentiable { + public enum CellType: Int, Sendable, Decodable, Equatable, Hashable { case textOnlyMessage case mediaMessage case audio @@ -103,104 +18,84 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case typingIndicator case dateHeader case unreadMarker - - /// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates - /// whether the given type is one of them - public var isPostProcessed: Bool { - switch self { - case .typingIndicator, .dateHeader, .unreadMarker: return true - default: return false - } - } } public var differenceIdentifier: Int64 { id } - // Thread Info + /// This value will be used to populate the Context Menu and date header (if present) + public var dateForUI: Date { Date(timeIntervalSince1970: TimeInterval(Double(self.timestampMs) / 1000)) } + + /// This value will be used to populate the Message Info (if present) + public var receivedDateForUI: Date { + Date(timeIntervalSince1970: TimeInterval(Double(self.receivedAtTimestampMs) / 1000)) + } + + /// This value defines what type of cell should appear and is generated based on the interaction variant + /// and associated attachment data + public let cellType: CellType + + /// This is a temporary id used before an outgoing message is persisted into the database + public let optimisticMessageId: Int64? + + // Thread Data public let threadId: String public let threadVariant: SessionThread.Variant public let threadIsTrusted: Bool - public let threadExpirationType: DisappearingMessagesConfiguration.DisappearingMessageType? - public let threadExpirationTimer: TimeInterval? - public let threadOpenGroupServer: String? - public let threadOpenGroupPublicKey: String? - private let threadContactNameInternal: String? - // Interaction Info + // Interaction Data - public let rowId: Int64 public let id: Int64 + public let variant: Interaction.Variant public let serverHash: String? public let openGroupServerMessageId: Int64? - public let variant: Interaction.Variant - public let timestampMs: Int64 - public let receivedAtTimestampMs: Int64 public let authorId: String - private let authorNameInternal: String? public let body: String? public let rawBody: String? + public let timestampMs: Int64 + public let receivedAtTimestampMs: Int64 public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? - public let isProMessage: Bool - - public let state: Interaction.State - public let hasBeenReadByRecipient: Bool - public let mostRecentFailureText: String? - public let isSenderModeratorOrAdmin: Bool - public let isTypingIndicator: Bool? - public let profile: Profile? + public let attachments: [Attachment] + public let reactionInfo: [ReactionInfo] + public let profile: Profile public let quotedInfo: QuotedInfo? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? - - public let currentUserSessionId: String - - // Post-Query Processing Data - - /// This value includes the associated attachments - public let attachments: [Attachment]? - - /// This value includes the associated reactions - public let reactionInfo: [ReactionInfo]? - - /// This value defines what type of cell should appear and is generated based on the interaction variant - /// and associated attachment data - public let cellType: CellType + public let proFeatures: SessionPro.Features /// This value includes the author name information public let authorName: String /// This value includes the author name information with the `id` suppressed (if it was present) public let authorNameSuppressedId: String - - /// This value will be used to populate the author label, if it's null then the label will be hidden - /// - /// **Note:** This will only be populated for incoming messages - public let senderName: String? + + public let state: Interaction.State + public let hasBeenReadByRecipient: Bool + public let mostRecentFailureText: String? + public let isSenderModeratorOrAdmin: Bool + public let canFollowDisappearingMessagesSetting: Bool + public let isProMessage: Bool + + // Display Properties + + /// A flag indicating whether the author name should be displayed + public let shouldShowAuthorName: Bool /// A flag indicating whether the profile view can be displayed public let canHaveProfile: Bool - /// A flag indicating whether the profile view should be displayed - public let shouldShowProfile: Bool + /// A flag indicating whether the display picture view should be displayed + public let shouldShowDisplayPicture: Bool /// A flag which controls whether the date header should be displayed public let shouldShowDateHeader: Bool - /// This value will be used to populate the Context Menu and date header (if present) - public var dateForUI: Date { Date(timeIntervalSince1970: TimeInterval(Double(self.timestampMs) / 1000)) } - - /// This value will be used to populate the Message Info (if present) - public var receivedDateForUI: Date { - Date(timeIntervalSince1970: TimeInterval(Double(self.receivedAtTimestampMs) / 1000)) - } - /// This value specifies whether the body contains only emoji characters - public let containsOnlyEmoji: Bool? + public let containsOnlyEmoji: Bool /// This value specifies the number of emoji characters the body contains - public let glyphCount: Int? + public let glyphCount: Int /// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item public let previousVariant: Interaction.Variant? @@ -214,196 +109,336 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value indicates whether this is the last message in the thread public let isLast: Bool + /// This value indicates whether this is the last outgoing message in the thread public let isLastOutgoing: Bool /// This contains all sessionId values for the current user (standard and any blinded variants) - public let currentUserSessionIds: Set? - - /// This is a temporary id used before an outgoing message is persisted into the database - public let optimisticMessageId: UUID? + public let currentUserSessionIds: Set +} - // MARK: - Mutation +public extension MessageViewModel { + private static let genericId: Int64 = -1 + private static let typingIndicatorId: Int64 = -2 - public func with( - state: Update = .useExisting, // Optimistic outgoing messages - mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages - profile: Update = .useExisting, - quotedInfo: Update = .useExisting, // Workaround for blinded current user - attachments: Update<[Attachment]?> = .useExisting, - reactionInfo: Update<[ReactionInfo]?> = .useExisting - ) -> MessageViewModel { - return MessageViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: self.body, - rawBody: self.rawBody, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - isProMessage: self.isProMessage, - state: state.or(self.state), - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: profile.or(self.profile), - quotedInfo: quotedInfo.or(self.quotedInfo), - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: attachments.or(self.attachments), - reactionInfo: reactionInfo.or(self.reactionInfo), - cellType: self.cellType, - authorName: self.authorName, - authorNameSuppressedId: self.authorNameSuppressedId, - senderName: self.senderName, - canHaveProfile: self.canHaveProfile, - shouldShowProfile: self.shouldShowProfile, - shouldShowDateHeader: self.shouldShowDateHeader, - containsOnlyEmoji: self.containsOnlyEmoji, - glyphCount: self.glyphCount, - previousVariant: self.previousVariant, - positionInCluster: self.positionInCluster, - isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast, - isLastOutgoing: self.isLastOutgoing, - currentUserSessionIds: self.currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId - ) + static var typingIndicator: MessageViewModel = MessageViewModel( + cellType: .typingIndicator, + timestampMs: 0 + ) + + init( + cellType: CellType, + timestampMs: Int64, + variant: Interaction.Variant = .standardOutgoing, + body: String? = nil, + quotedInfo: MessageViewModel.QuotedInfo? = nil, + isLast: Bool = true + ) { + self.id = { + switch cellType { + case .typingIndicator: return MessageViewModel.typingIndicatorId + case .dateHeader: return -timestampMs + default: return MessageViewModel.genericId + } + }() + self.cellType = cellType + self.timestampMs = timestampMs + self.variant = variant + self.body = body + self.quotedInfo = quotedInfo + + /// These values shouldn't be used for the custom types + self.optimisticMessageId = nil + self.threadId = "INVALID_THREAD_ID" + self.threadVariant = .contact + self.threadIsTrusted = false + self.serverHash = "" + self.openGroupServerMessageId = nil + self.authorId = "" + self.rawBody = nil + self.receivedAtTimestampMs = 0 + self.expiresStartedAtMs = nil + self.expiresInSeconds = nil + self.attachments = [] + self.reactionInfo = [] + self.profile = Profile(id: "", name: "") + self.linkPreview = nil + self.linkPreviewAttachment = nil + self.proFeatures = .none + + self.authorName = "" + self.authorNameSuppressedId = "" + self.state = .localOnly + self.hasBeenReadByRecipient = false + self.mostRecentFailureText = nil + self.isSenderModeratorOrAdmin = false + self.canFollowDisappearingMessagesSetting = false + self.isProMessage = false + + self.shouldShowAuthorName = false + self.canHaveProfile = false + self.shouldShowDisplayPicture = false + self.shouldShowDateHeader = false + self.containsOnlyEmoji = false + self.glyphCount = 0 + self.previousVariant = nil + + self.positionInCluster = .individual + self.isOnlyMessageInCluster = true + self.isLast = false + self.isLastOutgoing = false + self.currentUserSessionIds = [] } - public func withClusteringChanges( - prevModel: MessageViewModel?, - nextModel: MessageViewModel?, + init?( + optimisticMessageId: Int64? = nil, + threadId: String, + threadVariant: SessionThread.Variant, + threadIsTrusted: Bool, + threadDisappearingConfiguration: DisappearingMessagesConfiguration?, + interaction: Interaction, + reactionInfo: [ReactionInfo]?, + quotedInteraction: Interaction?, + profileCache: [String: Profile], + attachmentCache: [String: Attachment], + linkPreviewCache: [String: [LinkPreview]], + attachmentMap: [Int64: Set], + isSenderModeratorOrAdmin: Bool, + currentUserSessionIds: Set, + previousInteraction: Interaction?, + nextInteraction: Interaction?, isLast: Bool, isLastOutgoing: Bool, - currentUserSessionIds: Set, - currentUserProfile: Profile, - threadIsTrusted: Bool, using dependencies: Dependencies - ) -> MessageViewModel { - let cellType: CellType = { - guard self.isTypingIndicator != true else { return .typingIndicator } - guard !self.variant.isDeletedMessage else { return .textOnlyMessage } - guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage } - - // The only case which currently supports multiple attachments is a 'mediaMessage' - // (the album view) - guard self.attachments?.count == 1 else { return .mediaMessage } - - // Pending audio attachments won't have a duration - if - attachment.isAudio && ( - ((attachment.duration ?? 0) > 0) || - ( - attachment.state != .downloaded && - attachment.state != .uploaded - ) - ) - { - return (attachment.variant == .voiceMessage ? .voiceMessage : .audio) - } - - if attachment.isVisualMedia { - return .mediaMessage - } - - return .genericAttachment - }() - // TODO: [Database Relocation] Clean up `currentUserProfile` logic (profile data should be sourced from a separate query for efficiency) + ) { + let targetId: Int64 + + switch (optimisticMessageId, interaction.id) { + case (.some(let id), _): targetId = id + case (_, .some(let id)): targetId = id + case (.none, .none): return nil + } + let authorDisplayName: String = { - guard authorId != currentUserProfile.id else { - return currentUserProfile.displayName( - for: self.threadVariant, - ignoringNickname: true, // Current user has no nickname - suppressId: false // Show the id next to the author name if desired - ) - } + guard !currentUserSessionIds.contains(interaction.authorId) else { return "you".localized() } return Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query + for: threadVariant, + id: interaction.authorId, + name: profileCache[interaction.authorId]?.name, + nickname: profileCache[interaction.authorId]?.nickname, suppressId: false // Show the id next to the author name if desired ) }() - let authorDisplayNameSuppressedId: String = { - guard authorId != currentUserProfile.id else { - return currentUserProfile.displayName( - for: self.threadVariant, - ignoringNickname: true, // Current user has no nickname - suppressId: true // Exclude the id next to the author name - ) - } + let linkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = interaction.linkPreview( + linkPreviewCache: linkPreviewCache, + attachmentCache: attachmentCache + ) + let attachments: [Attachment] = (attachmentMap[targetId]? + .sorted { $0.albumIndex < $1.albumIndex } + .compactMap { attachmentCache[$0.attachmentId] } ?? []) + let body: String? = interaction.body( + threadId: threadId, + threadVariant: threadVariant, + authorDisplayName: authorDisplayName, + attachments: attachments, + linkPreview: linkPreviewInfo?.preview, + profileCache: profileCache, + using: dependencies + ) + + self.cellType = MessageViewModel.cellType( + interaction: interaction, + attachments: attachments + ) + self.optimisticMessageId = optimisticMessageId + self.threadId = threadId + self.threadVariant = threadVariant + self.threadIsTrusted = threadIsTrusted + self.id = targetId + self.variant = interaction.variant + self.serverHash = interaction.serverHash + self.openGroupServerMessageId = interaction.openGroupServerMessageId + self.authorId = interaction.authorId + self.body = body + self.rawBody = interaction.body + self.timestampMs = interaction.timestampMs + self.receivedAtTimestampMs = interaction.receivedAtTimestampMs + self.expiresStartedAtMs = interaction.expiresStartedAtMs + self.expiresInSeconds = interaction.expiresInSeconds + self.attachments = attachments + self.reactionInfo = (reactionInfo ?? []) + self.profile = (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) // TODO: [PRO] Do we want this???. + self.quotedInfo = quotedInteraction.map { quotedInteraction -> QuotedInfo? in + guard let quoteInteractionId: Int64 = quotedInteraction.id else { return nil } + + let quotedAttachments: [Attachment]? = (attachmentMap[quotedInteraction.id]? + .sorted { $0.albumIndex < $1.albumIndex } + .compactMap { attachmentCache[$0.attachmentId] } ?? []) + let quotedLinkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = quotedInteraction.linkPreview( + linkPreviewCache: linkPreviewCache, + attachmentCache: attachmentCache + ) + + return MessageViewModel.QuotedInfo( + interactionId: quoteInteractionId, + authorName: { + guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { + return "you".localized() + } + + return Profile.displayName( + for: threadVariant, + id: quotedInteraction.authorId, + name: profileCache[quotedInteraction.authorId]?.name, + nickname: profileCache[quotedInteraction.authorId]?.nickname, + suppressId: true + ) + }(), + timestampMs: quotedInteraction.timestampMs, + body: quotedInteraction.body( + threadId: threadId, + threadVariant: threadVariant, + authorDisplayName: authorDisplayName, + attachments: quotedAttachments, + linkPreview: quotedLinkPreviewInfo?.preview, + profileCache: profileCache, + using: dependencies + ), + attachment: (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment), + proFeatures: .none // TODO: [PRO] Need to get this from the message + ) + } + self.linkPreview = linkPreviewInfo?.preview + self.linkPreviewAttachment = linkPreviewInfo?.attachment + self.proFeatures = .none // TODO: [PRO] Need to get this from the message + + self.authorName = authorDisplayName + self.authorNameSuppressedId = { + guard !currentUserSessionIds.contains(interaction.authorId) else { return "you".localized() } return Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query + for: threadVariant, + id: interaction.authorId, + name: profileCache[interaction.authorId]?.name, + nickname: profileCache[interaction.authorId]?.nickname, suppressId: true // Exclude the id next to the author name ) }() + + self.state = interaction.state + self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) + self.mostRecentFailureText = interaction.mostRecentFailureText + self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin + self.canFollowDisappearingMessagesSetting = { + guard + threadVariant == .contact && + interaction.variant == .infoDisappearingMessagesUpdate && + !currentUserSessionIds.contains(interaction.authorId) + else { return false } + + return ( + threadDisappearingConfiguration != DisappearingMessagesConfiguration + .defaultWith(threadId) + .with( + isEnabled: (interaction.expiresInSeconds ?? 0) > 0, + durationSeconds: interaction.expiresInSeconds, + type: (Int64(interaction.expiresStartedAtMs ?? 0) == interaction.timestampMs ? + .disappearAfterSend : + .disappearAfterRead + ) + ) + ) + }() + self.isProMessage = false // TODO: [PRO] Need to replace this. + + let isGroupThread: Bool = ( + threadVariant == .community || + threadVariant == .legacyGroup || + threadVariant == .group + ) let shouldShowDateBeforeThisModel: Bool = { - guard self.isTypingIndicator != true else { return false } - guard self.variant != .infoCall else { return true } // Always show on calls - guard !self.variant.isInfoMessage else { return false } // Never show on info messages - guard let prevModel: ViewModel = prevModel else { return true } + guard interaction.variant != .infoCall else { return true } /// Always show on calls + guard !interaction.variant.isInfoMessage else { return false } /// Never show on info messages + guard let previousInteraction: Interaction = previousInteraction else { return true } return MessageViewModel.shouldShowDateBreak( - between: prevModel.timestampMs, - and: self.timestampMs + between: previousInteraction.timestampMs, + and: interaction.timestampMs ) }() let shouldShowDateBeforeNextModel: Bool = { - // Should be nothing after a typing indicator - guard self.isTypingIndicator != true else { return false } - guard let nextModel: ViewModel = nextModel else { return false } + /// Should be nothing after a typing indicator + guard let nextInteraction: Interaction = nextInteraction else { return false } return MessageViewModel.shouldShowDateBreak( - between: self.timestampMs, - and: nextModel.timestampMs + between: interaction.timestampMs, + and: nextInteraction.timestampMs ) }() + self.shouldShowAuthorName = { + /// Only show for group threads + guard isGroupThread else { return false } + + /// Only show for incoming messages + guard interaction.variant.isIncoming else { return false } + + /// Only if there is a date header or the senders are different + guard + shouldShowDateBeforeThisModel || + interaction.authorId != previousInteraction?.authorId || + previousInteraction?.variant.isInfoMessage == true + else { return false } + + return true + }() + self.canHaveProfile = ( + /// Only group threads and incoming messages + isGroupThread && + interaction.variant.isIncoming + ) + self.shouldShowDisplayPicture = ( + /// Only group threads + isGroupThread && + + /// Only incoming messages + interaction.variant.isIncoming && + + /// Show if the next message has a different sender, isn't a standard message or has a "date break" + ( + interaction.authorId != nextInteraction?.authorId || + nextInteraction?.variant.isIncoming != true || + shouldShowDateBeforeNextModel + ) + ) + self.shouldShowDateHeader = shouldShowDateBeforeThisModel + self.containsOnlyEmoji = (body?.containsOnlyEmoji == true) + self.glyphCount = (body?.glyphCount ?? 0) + self.previousVariant = previousInteraction?.variant + let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { let isFirstInCluster: Bool = ( - self.variant.isInfoMessage || - prevModel == nil || + interaction.variant.isInfoMessage || + previousInteraction == nil || shouldShowDateBeforeThisModel || ( - self.variant.isOutgoing && - prevModel?.variant.isOutgoing != true + interaction.variant.isOutgoing && + previousInteraction?.variant.isOutgoing != true ) || ( - self.variant.isIncoming && - prevModel?.variant.isIncoming != true + interaction.variant.isIncoming && + previousInteraction?.variant.isIncoming != true ) || - self.authorId != prevModel?.authorId + interaction.authorId != previousInteraction?.authorId ) let isLastInCluster: Bool = ( - self.variant.isInfoMessage || - nextModel == nil || + interaction.variant.isInfoMessage || + nextInteraction == nil || shouldShowDateBeforeNextModel || ( - self.variant.isOutgoing && - prevModel?.variant.isOutgoing != true + interaction.variant.isOutgoing && + nextInteraction?.variant.isOutgoing != true ) || ( - self.variant.isIncoming && - prevModel?.variant.isIncoming != true + interaction.variant.isIncoming && + nextInteraction?.variant.isIncoming != true ) || - self.authorId != nextModel?.authorId + interaction.authorId != nextInteraction?.authorId ) let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster) @@ -414,127 +449,62 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case (false, true): return (.bottom, isOnlyMessageInCluster) } }() - let isGroupThread: Bool = ( - self.threadVariant == .community || - self.threadVariant == .legacyGroup || - self.threadVariant == .group - ) - return ViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: (threadIsTrusted || self.threadIsTrusted), - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: (self.threadId == currentUserProfile.id ? - "you".localized() : - self.authorNameInternal - ), - body: (!self.variant.isInfoMessage ? - self.body : - // Info messages might not have a body so we should use the 'previewText' value instead - Interaction.previewText( - variant: self.variant, - body: self.body, - threadContactDisplayName: Profile.displayName( - for: self.threadVariant, - id: self.threadId, - name: self.threadContactNameInternal, - nickname: nil, // Folded into 'threadContactNameInternal' within the Query - suppressId: false // Show the id next to the author name if desired - ), - authorDisplayName: authorDisplayName, - attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in - Attachment.DescriptionInfo( - id: firstAttachment.id, - variant: firstAttachment.variant, - contentType: firstAttachment.contentType, - sourceFilename: firstAttachment.sourceFilename - ) - }, - attachmentCount: self.attachments?.count, - isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation), - using: dependencies - ) - ), - rawBody: self.body, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - isProMessage: self.isProMessage, - state: self.state, - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: self.mostRecentFailureText, - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: (self.profile?.id == currentUserProfile.id ? currentUserProfile : self.profile), - quotedInfo: self.quotedInfo, - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: self.attachments, - reactionInfo: self.reactionInfo, + self.positionInCluster = positionInCluster + self.isOnlyMessageInCluster = isOnlyMessageInCluster + self.isLast = isLast + self.isLastOutgoing = isLastOutgoing + self.currentUserSessionIds = currentUserSessionIds + } + + func with( + state: Update = .useExisting, // Optimistic outgoing messages + mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages + ) -> MessageViewModel { + return MessageViewModel( cellType: cellType, - authorName: authorDisplayName, - authorNameSuppressedId: authorDisplayNameSuppressedId, - senderName: { - // Only show for group threads - guard isGroupThread else { return nil } - - // Only show for incoming messages - guard self.variant.isIncoming else { return nil } - - // Only if there is a date header or the senders are different - guard - shouldShowDateBeforeThisModel || - self.authorId != prevModel?.authorId || - prevModel?.variant.isInfoMessage == true - else { return nil } - - return authorDisplayName - }(), - canHaveProfile: ( - // Only group threads and incoming messages - isGroupThread && - self.variant.isIncoming - ), - shouldShowProfile: ( - // Only group threads - isGroupThread && - - // Only incoming messages - self.variant.isIncoming && - - // Show if the next message has a different sender, isn't a standard message or has a "date break" - ( - self.authorId != nextModel?.authorId || - nextModel?.variant.isIncoming != true || - shouldShowDateBeforeNextModel - ) && - - // Need a profile to be able to show it - self.profile != nil - ), - shouldShowDateHeader: shouldShowDateBeforeThisModel, - containsOnlyEmoji: self.body?.containsOnlyEmoji, - glyphCount: self.body?.glyphCount, - previousVariant: prevModel?.variant, + optimisticMessageId: optimisticMessageId, + threadId: threadId, + threadVariant: threadVariant, + threadIsTrusted: threadIsTrusted, + id: id, + variant: variant, + serverHash: serverHash, + openGroupServerMessageId: openGroupServerMessageId, + authorId: authorId, + body: body, + rawBody: rawBody, + timestampMs: timestampMs, + receivedAtTimestampMs: receivedAtTimestampMs, + expiresStartedAtMs: expiresStartedAtMs, + expiresInSeconds: expiresInSeconds, + attachments: attachments, + reactionInfo: reactionInfo, + profile: profile, + quotedInfo: quotedInfo, + linkPreview: linkPreview, + linkPreviewAttachment: linkPreviewAttachment, + proFeatures: proFeatures, + authorName: authorName, + authorNameSuppressedId: authorNameSuppressedId, + state: state.or(self.state), + hasBeenReadByRecipient: hasBeenReadByRecipient, + mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), + isSenderModeratorOrAdmin: isSenderModeratorOrAdmin, + canFollowDisappearingMessagesSetting: canFollowDisappearingMessagesSetting, + isProMessage: isProMessage, + shouldShowAuthorName: shouldShowAuthorName, + canHaveProfile: canHaveProfile, + shouldShowDisplayPicture: shouldShowDisplayPicture, + shouldShowDateHeader: shouldShowDateHeader, + containsOnlyEmoji: containsOnlyEmoji, + glyphCount: glyphCount, + previousVariant: previousVariant, positionInCluster: positionInCluster, isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, - currentUserSessionIds: currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId + currentUserSessionIds: currentUserSessionIds ) } } @@ -551,74 +521,23 @@ public extension MessageViewModel { type: (Int64(self.expiresStartedAtMs ?? 0) == self.timestampMs ? .disappearAfterSend : .disappearAfterRead ) ) } - - func threadDisappearingConfiguration() -> DisappearingMessagesConfiguration { - return DisappearingMessagesConfiguration - .defaultWith(self.threadId) - .with( - isEnabled: (self.threadExpirationTimer ?? 0) > 0, - durationSeconds: self.threadExpirationTimer, - type: self.threadExpirationType - ) - } - - func canDoFollowingSetting() -> Bool { - guard self.variant == .infoDisappearingMessagesUpdate else { return false } - guard self.authorId != self.currentUserSessionId else { return false } - guard self.threadVariant == .contact else { return false } - return self.messageDisappearingConfiguration() != self.threadDisappearingConfiguration() - } -} - -// MARK: - AttachmentInteractionInfo - -public extension MessageViewModel { - struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case attachment - case interactionAttachment - } - - public let rowId: Int64 - public let attachment: Attachment - public let interactionAttachment: InteractionAttachment - - // MARK: - Identifiable - - public var id: String { - "\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)" - } - - // MARK: - Comparable - - public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool { - return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex) - } - } } // MARK: - ReactionInfo public extension MessageViewModel { - struct ReactionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable, Hashable, Differentiable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case reaction - case profile - } - - public let rowId: Int64 + struct ReactionInfo: Sendable, Equatable, Comparable, Hashable, Differentiable { public let reaction: Reaction public let profile: Profile? - // MARK: - Identifiable + public init(reaction: Reaction, profile: Profile?) { + self.reaction = reaction + self.profile = profile + } - public var differenceIdentifier: String { return id } + // MARK: - Differentiable - public var id: String { + public var differenceIdentifier: String { "\(reaction.emoji)-\(reaction.interactionId)-\(reaction.authorId)" } @@ -652,234 +571,58 @@ public extension MessageViewModel { // MARK: - QuotedInfo public extension MessageViewModel { - struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case interactionId - case authorId - case timestampMs - case body - case attachment - case quotedInteractionId - case quotedInteractionVariant - } - - public let rowId: Int64 + struct QuotedInfo: Sendable, Equatable, Hashable { public let interactionId: Int64 - public let authorId: String + public let authorName: String public let timestampMs: Int64 public let body: String? public let attachment: Attachment? - public let quotedInteractionId: Int64 - public let quotedInteractionVariant: Interaction.Variant - - // MARK: - Identifiable - - public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } + public let proFeatures: SessionPro.Features // MARK: - Initialization + public init( + interactionId: Int64, + authorName: String, + timestampMs: Int64, + body: String?, + attachment: Attachment?, + proFeatures: SessionPro.Features + ) { + self.interactionId = interactionId + self.authorName = authorName + self.timestampMs = timestampMs + self.body = body + self.attachment = attachment + self.proFeatures = proFeatures + } + public init(previewBody: String) { self.body = previewBody - /// This is an preview version so none of these values matter - self.rowId = -1 + /// This is a preview version so none of these values matter self.interactionId = -1 - self.authorId = "" + self.authorName = "" self.timestampMs = 0 self.attachment = nil - self.quotedInteractionId = -1 - self.quotedInteractionVariant = .standardOutgoing + self.proFeatures = .none } - public init?(replyModel: QuotedReplyModel?) { + public init?(replyModel: QuotedReplyModel?, authorName: String?) { guard let model: QuotedReplyModel = replyModel else { return nil } - self.authorId = model.authorId + self.authorName = (authorName ?? model.authorId.truncated()) self.timestampMs = model.timestampMs self.body = model.body self.attachment = model.attachment + self.proFeatures = model.proFeatures /// This is an optimistic version so none of these values exist yet - self.rowId = -1 self.interactionId = -1 - self.quotedInteractionId = -1 - self.quotedInteractionVariant = .standardOutgoing } } } -// MARK: - Convenience Initialization - -public extension MessageViewModel { - static let genericId: Int64 = -1 - static let typingIndicatorId: Int64 = -2 - static let optimisticUpdateId: Int64 = -3 - - /// This init method is only used for system-created cells or empty states - init( - variant: Interaction.Variant = .standardOutgoing, - timestampMs: Int64 = Int64.max, - receivedAtTimestampMs: Int64 = Int64.max, - body: String? = nil, - quotedInfo: QuotedInfo? = nil, - cellType: CellType = .typingIndicator, - isTypingIndicator: Bool? = nil, - isLast: Bool = true, - isLastOutgoing: Bool = false - ) { - self.threadId = "INVALID_THREAD_ID" - self.threadVariant = .contact - self.threadIsTrusted = false - self.threadExpirationType = nil - self.threadExpirationTimer = nil - self.threadOpenGroupServer = nil - self.threadOpenGroupPublicKey = nil - self.threadContactNameInternal = nil - - // Interaction Info - - let targetId: Int64 = { - guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId } - guard cellType != .dateHeader else { return -timestampMs } - - return MessageViewModel.genericId - }() - self.rowId = targetId - self.id = targetId - self.serverHash = nil - self.openGroupServerMessageId = nil - self.variant = variant - self.timestampMs = timestampMs - self.receivedAtTimestampMs = receivedAtTimestampMs - self.authorId = "" - self.authorNameInternal = nil - self.body = body - self.rawBody = nil - self.expiresStartedAtMs = nil - self.expiresInSeconds = nil - self.isProMessage = false - - self.state = .sent - self.hasBeenReadByRecipient = false - self.mostRecentFailureText = nil - self.isSenderModeratorOrAdmin = false - self.isTypingIndicator = isTypingIndicator - self.profile = nil - self.quotedInfo = quotedInfo - self.linkPreview = nil - self.linkPreviewAttachment = nil - self.currentUserSessionId = "" - self.attachments = nil - self.reactionInfo = nil - - // Post-Query Processing Data - - self.cellType = cellType - self.authorName = "" - self.authorNameSuppressedId = "" - self.senderName = nil - self.canHaveProfile = false - self.shouldShowProfile = false - self.shouldShowDateHeader = false - self.containsOnlyEmoji = nil - self.glyphCount = nil - self.previousVariant = nil - self.positionInCluster = .middle - self.isOnlyMessageInCluster = true - self.isLast = isLast - self.isLastOutgoing = isLastOutgoing - self.currentUserSessionIds = [currentUserSessionId] - self.optimisticMessageId = nil - } - - /// This init method is only used for optimistic outgoing messages - init( - optimisticMessageId: UUID, - threadId: String, - threadVariant: SessionThread.Variant, - threadExpirationType: DisappearingMessagesConfiguration.DisappearingMessageType?, - threadExpirationTimer: TimeInterval?, - threadOpenGroupServer: String?, - threadOpenGroupPublicKey: String?, - threadContactNameInternal: String, - timestampMs: Int64, - receivedAtTimestampMs: Int64, - authorId: String, - authorNameInternal: String, - body: String?, - expiresStartedAtMs: Double?, - expiresInSeconds: TimeInterval?, - isProMessage: Bool, - state: Interaction.State = .sending, - isSenderModeratorOrAdmin: Bool, - currentUserProfile: Profile, - quotedInfo: QuotedInfo?, - linkPreview: LinkPreview?, - linkPreviewAttachment: Attachment?, - attachments: [Attachment]? - ) { - self.threadId = threadId - self.threadVariant = threadVariant - self.threadIsTrusted = false - self.threadExpirationType = threadExpirationType - self.threadExpirationTimer = threadExpirationTimer - self.threadOpenGroupServer = threadOpenGroupServer - self.threadOpenGroupPublicKey = threadOpenGroupPublicKey - self.threadContactNameInternal = threadContactNameInternal - - // Interaction Info - - self.rowId = MessageViewModel.optimisticUpdateId - self.id = MessageViewModel.optimisticUpdateId - self.serverHash = nil - self.openGroupServerMessageId = nil - self.variant = .standardOutgoing - self.timestampMs = timestampMs - self.receivedAtTimestampMs = receivedAtTimestampMs - self.authorId = authorId - self.authorNameInternal = authorNameInternal - self.body = body - self.rawBody = body - self.expiresStartedAtMs = expiresStartedAtMs - self.expiresInSeconds = expiresInSeconds - self.isProMessage = isProMessage - - self.state = state - self.hasBeenReadByRecipient = false - self.mostRecentFailureText = nil - self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin - self.isTypingIndicator = false - self.profile = currentUserProfile - self.quotedInfo = quotedInfo - self.linkPreview = linkPreview - self.linkPreviewAttachment = linkPreviewAttachment - self.currentUserSessionId = currentUserProfile.id - self.attachments = attachments - self.reactionInfo = nil - - // Post-Query Processing Data - - self.cellType = .textOnlyMessage - self.authorName = "" - self.authorNameSuppressedId = "" - self.senderName = nil - self.canHaveProfile = false - self.shouldShowProfile = false - self.shouldShowDateHeader = false - self.containsOnlyEmoji = nil - self.glyphCount = nil - self.previousVariant = nil - self.positionInCluster = .middle - self.isOnlyMessageInCluster = true - self.isLast = false - self.isLastOutgoing = false - self.currentUserSessionIds = [currentUserProfile.id] - self.optimisticMessageId = optimisticMessageId - } -} - // MARK: - Convenience extension MessageViewModel { @@ -918,491 +661,152 @@ extension MessageViewModel { } } -// MARK: - ConversationVC - -// MARK: --MessageViewModel - public extension MessageViewModel { - static func filterSQL(threadId: String) -> SQL { + static func interactionFilterSQL(threadId: String) -> SQL { let interaction: TypedTableAlias = TypedTableAlias() return SQL("\(interaction[.threadId]) = \(threadId)") } - static let groupSQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("GROUP BY \(interaction[.id])") - }() - - static let orderSQL: SQL = { + static let interactionOrderSQL: SQL = { let interaction: TypedTableAlias = TypedTableAlias() return SQL("\(interaction[.timestampMs].desc)") }() - static func baseQuery( - userSessionId: SessionId, - currentUserSessionIds: Set, - orderSQL: SQL, - groupSQL: SQL? - ) -> (([Int64]) -> AdaptedFetchRequest>) { - return { rowIds -> AdaptedFetchRequest> in - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let groupMember: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") - let linkPreview: TypedTableAlias = TypedTableAlias() - let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) - - let numColumnsBeforeLinkedRecords: Int = 25 - let finalGroupSQL: SQL = (groupSQL ?? "") - let request: SQLRequest = """ - SELECT - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - -- Default to 'true' for non-contact threads - IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.Columns.threadIsTrusted), - \(disappearingMessagesConfig[.type]) AS \(ViewModel.Columns.threadExpirationType), - \(disappearingMessagesConfig[.durationSeconds]) AS \(ViewModel.Columns.threadExpirationTimer), - \(openGroup[.server]) AS \(ViewModel.Columns.threadOpenGroupServer), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.threadOpenGroupPublicKey), - IFNULL(\(threadProfile[.nickname]), \(threadProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - - \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), - \(interaction[.id]), - \(interaction[.serverHash]), - \(interaction[.openGroupServerMessageId]), - \(interaction[.variant]), - \(interaction[.timestampMs]), - \(interaction[.receivedAtTimestampMs]), - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(interaction[.body]), - \(interaction[.expiresStartedAtMs]), - \(interaction[.expiresInSeconds]), - \(interaction[.isProMessage]), - \(interaction[.state]), - (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasBeenReadByRecipient), - \(interaction[.mostRecentFailureText]), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(interaction[.threadId]) AND - \(groupMember[.profileId]) = \(interaction[.authorId]) AND - \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) - ) - ) AS \(ViewModel.Columns.isSenderModeratorOrAdmin), - - \(profile.allColumns), - \(linkPreview.allColumns), - \(linkPreviewAttachment.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId), - - -- All of the below properties are set in post-query processing but to prevent the - -- query from crashing when decoding we need to provide default values - \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), - '' AS \(ViewModel.Columns.authorName), - '' AS \(ViewModel.Columns.authorNameSuppressedId), - false AS \(ViewModel.Columns.canHaveProfile), - false AS \(ViewModel.Columns.shouldShowProfile), - false AS \(ViewModel.Columns.shouldShowDateHeader), - \(Position.middle) AS \(ViewModel.Columns.positionInCluster), - false AS \(ViewModel.Columns.isOnlyMessageInCluster), - false AS \(ViewModel.Columns.isLast), - false AS \(ViewModel.Columns.isLastOutgoing) - - FROM \(Interaction.self) - JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(threadProfile) ON \(threadProfile[.id]) = \(interaction[.threadId]) - LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) - ) - LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId]) - - WHERE \(interaction[.rowId]) IN \(rowIds) - \(finalGroupSQL) - ORDER BY \(orderSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Profile.numberOfSelectedColumns(db), - LinkPreview.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .profile: adapters[1], - .linkPreview: adapters[2], - .linkPreviewAttachment: adapters[3] - ]) - } - } - } -} - -// MARK: --AttachmentInteractionInfo - -public extension MessageViewModel.AttachmentInteractionInfo { - static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - SELECT - \(attachment[.rowId]) AS \(AttachmentInteractionInfo.Columns.rowId), - \(attachment.allColumns), - \(interactionAttachment.allColumns) - FROM \(Attachment.self) - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Attachment.numberOfSelectedColumns(db), - InteractionAttachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(AttachmentInteractionInfo.self, [ - .attachment: adapters[1], - .interactionAttachment: adapters[2] - ]) - } - } - }() - - static var joinToViewModelQuerySQL: SQL = { + static func quotedInteractionIds( + for originalInteractionIds: [Int64], + currentUserSessionIds: Set + ) -> SQLRequest> { let interaction: TypedTableAlias = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") return """ - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - JOIN \(Attachment.self) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - - dataCache - .values - .grouped(by: \.interactionAttachment.interactionId) - .forEach { (interactionId: Int64, attachments: [MessageViewModel.AttachmentInteractionInfo]) in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - attachments: .set(to: attachments - .sorted() - .map { $0.attachment }) - ) + SELECT + \(interaction[.id]) AS \(FetchablePair.Columns.first), + \(quoteInteraction[.id]) AS \(FetchablePair.Columns.second) + FROM \(Interaction.self) + JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + JOIN \(quoteInteraction) ON ( + \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( + \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( + -- A users outgoing message is stored in some cases using their standard id + -- but the quote will use their blinded id so handle that case + \(quoteInteraction[.authorId]) IN \(currentUserSessionIds) AND + \(quote[.authorId]) IN \(currentUserSessionIds) ) - } - - return updatedPagedDataCache - } + ) + ) + WHERE \(interaction[.id]) IN \(originalInteractionIds) + """ } } -// MARK: --ReactionInfo +// MARK: - Construction -public extension MessageViewModel.ReactionInfo { - static let baseQuery: ((SQL?) -> AdaptedFetchRequest>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let reaction: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - SELECT - \(reaction[.rowId]) AS \(ReactionInfo.Columns.rowId), - \(reaction.allColumns), - \(profile.allColumns) - FROM \(Reaction.self) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(reaction[.authorId]) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Reaction.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ReactionInfo.self, [ - .reaction: adapters[1], - .profile: adapters[2] - ]) - } - } - }() - - static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let reaction: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(Reaction.self) ON \(reaction[.interactionId]) = \(interaction[.id]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache - var pagedRowIdsWithNoReactions: Set = Set(pagedDataCache.data.keys) - - // Add any new reactions - dataCache - .values - .grouped(by: \.reaction.interactionId) - .forEach { (interactionId: Int64, reactionInfo: [MessageViewModel.ReactionInfo]) in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: .set(to: reactionInfo.sorted())) - ) - pagedRowIdsWithNoReactions.remove(interactionRowId) - } - - // Remove any removed reactions - updatedPagedDataCache = updatedPagedDataCache.upserting( - items: pagedRowIdsWithNoReactions - .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } - .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: .set(to: nil)) } +private extension MessageViewModel { + static func cellType( + interaction: Interaction, + attachments: [Attachment]? + ) -> MessageViewModel.CellType { + guard !interaction.variant.isDeletedMessage else { return .textOnlyMessage } + guard let attachment: Attachment = attachments?.first else { return .textOnlyMessage } + + /// The only case which currently supports multiple attachments is a 'mediaMessage' (the album view) + guard attachments?.count == 1 else { return .mediaMessage } + + // Pending audio attachments won't have a duration + if + attachment.isAudio && ( + ((attachment.duration ?? 0) > 0) || + ( + attachment.state != .downloaded && + attachment.state != .uploaded + ) ) - - return updatedPagedDataCache + { + return (attachment.variant == .voiceMessage ? .voiceMessage : .audio) } - } -} - -// MARK: --TypingIndicatorInfo -public extension MessageViewModel.TypingIndicatorInfo { - static let baseQuery: ((SQL?) -> SQLRequest) = { - return { additionalFilters -> SQLRequest in - let threadTypingIndicator: TypedTableAlias = TypedTableAlias() - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let request: SQLRequest = """ - SELECT - \(threadTypingIndicator[.rowId]), - \(threadTypingIndicator[.threadId]) - FROM \(ThreadTypingIndicator.self) - \(finalFilterSQL) - """ - - return request + if attachment.isVisualMedia { + return .mediaMessage } - }() - - static var joinToViewModelQuerySQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - let threadTypingIndicator: TypedTableAlias = TypedTableAlias() - return """ - JOIN \(ThreadTypingIndicator.self) ON \(threadTypingIndicator[.threadId]) = \(interaction[.threadId]) - """ - }() - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - guard !dataCache.data.isEmpty else { - return pagedDataCache.deleting(rowIds: [MessageViewModel.typingIndicatorId]) - } - - return pagedDataCache - .upserting(MessageViewModel(isTypingIndicator: true)) - } + return .genericAttachment } } -// MARK: --QuotedInfo - -public extension MessageViewModel.QuotedInfo { - static func baseQuery( - userSessionId: SessionId, - currentUserSessionIds: Set - ) -> ((SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters -> AdaptedFetchRequest> in - let quote: TypedTableAlias = TypedTableAlias() - let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") - let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( - name: "quoteInteractionAttachment" - ) - let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") - let attachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - - let numColumnsBeforeLinkedRecords: Int = 5 - let request: SQLRequest = """ - SELECT - \(quote[.rowId]) AS \(QuotedInfo.Columns.rowId), - \(quote[.interactionId]) AS \(QuotedInfo.Columns.interactionId), - \(quote[.authorId]) AS \(QuotedInfo.Columns.authorId), - \(quote[.timestampMs]) AS \(QuotedInfo.Columns.timestampMs), - \(quoteInteraction[.body]) AS \(QuotedInfo.Columns.body), - \(attachment.allColumns), - \(quoteInteraction[.id]) AS \(QuotedInfo.Columns.quotedInteractionId), - \(quoteInteraction[.variant]) AS \(QuotedInfo.Columns.quotedInteractionVariant) - FROM \(Quote.self) - JOIN \(quoteInteraction) ON ( - \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( - \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( - -- A users outgoing message is stored in some cases using their standard id - -- but the quote will use their blinded id so handle that case - \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - \(quote[.authorId]) IN \(currentUserSessionIds) - ) - ) - ) - LEFT JOIN \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND - \(quoteInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(quoteLinkPreview) ON ( - \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral( - interaction: quoteInteraction, - linkPreview: quoteLinkPreview - )) - ) - LEFT JOIN \(Attachment.self) ON ( - \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(attachment[.id]) = \(quoteLinkPreview[.attachmentId]) +private extension Interaction { + func body( + threadId: String, + threadVariant: SessionThread.Variant, + authorDisplayName: String, + attachments: [Attachment]?, + linkPreview: LinkPreview?, + profileCache: [String: Profile], + using dependencies: Dependencies + ) -> String? { + guard variant.isInfoMessage else { return body } + + /// Info messages might not have a body so we should use the 'previewText' value instead + return Interaction.previewText( + variant: variant, + body: body, + threadContactDisplayName: Profile.displayName( + for: threadVariant, + id: threadId, + name: profileCache[authorId]?.name, + nickname: profileCache[authorId]?.nickname, + suppressId: false // Show the id next to the author name if desired + ), + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: attachments?.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename ) - \(finalFilterSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeLinkedRecords, - Attachment.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(QuotedInfo.self, [ - .attachment: adapters[1] - ]) - } - } - } - - static func joinToViewModelQuerySQL() -> SQL { - let quote: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - """ + }, + attachmentCount: attachments?.count, + isOpenGroupInvitation: (linkPreview?.variant == .openGroupInvitation), + using: dependencies + ) } - static func createReferencedRowIdsRetriever() -> (([Int64], DataCache) -> [Int64]) { - return { pagedRowIds, dataCache -> [Int64] in - dataCache.values.compactMap { quotedInfo in - guard - pagedRowIds.contains(quotedInfo.quotedInteractionId) || - pagedRowIds.contains(quotedInfo.interactionId) - else { return nil } + func linkPreview( + linkPreviewCache: [String: [LinkPreview]], + attachmentCache: [String: Attachment], + ) -> (preview: LinkPreview, attachment: Attachment?)? { + let preview: LinkPreview? = linkPreviewUrl.map { url -> LinkPreview? in + /// Find all previews for the given url and sort by newest to oldest + guard let possiblePreviews: [LinkPreview] = linkPreviewCache[url]?.sorted(by: { lhs, rhs in + guard lhs.timestamp != rhs.timestamp else { + /// If the timestamps match then it's likely there is an optimistic link preview in the cache, so if one of the options + /// has an `attachmentId` then we should prioritise that one + switch (lhs.attachmentId, rhs.attachmentId) { + case (.some, .none): return true + case (.none, .some): return false + case (.some, .some), (.none, .none): return true /// Whatever was added to the cache first wins + } + } - return quotedInfo.rowId - } - } - } - - static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = pagedDataCache + return lhs.timestamp > rhs.timestamp + }) else { return nil } - // Update changed records - dataCache.values.forEach { quoteInfo in - guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.interactionId], - let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] - else { return } - - switch quoteInfo.quotedInteractionVariant.isDeletedMessage { - // If the original message wasn't deleted and the quote contains some of it's content - // then remove that content from the quote - case false: - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quotedInfo: .set(to: quoteInfo)) - ) - - // If the original message was deleted and the quote contains some of it's content - // then remove that content from the quote - case true: - guard dataToUpdate.quotedInfo != nil else { return } - - updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(quotedInfo: .set(to: nil)) - ) - } + /// Try get the link preview for the time the message was sent + let minTimestamp: TimeInterval = (TimeInterval(timestampMs / 1000) - LinkPreview.timstampResolution) + let maxTimestamp: TimeInterval = (TimeInterval(timestampMs / 1000) + LinkPreview.timstampResolution) + let targetPreview: LinkPreview? = possiblePreviews.first { + $0.timestamp > minTimestamp && + $0.timestamp < maxTimestamp } - return updatedPagedDataCache + /// Fallback to the newest preview + return (targetPreview ?? possiblePreviews.first) } + + return preview.map { ($0, $0.attachmentId.map { attachmentCache[$0] }) } } } diff --git a/SessionMessagingKit/Shared Models/Position.swift b/SessionMessagingKit/Shared Models/Position.swift index bfa148d8be..1f8378de61 100644 --- a/SessionMessagingKit/Shared Models/Position.swift +++ b/SessionMessagingKit/Shared Models/Position.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible { +public enum Position: Int, Sendable, Decodable, Equatable, Hashable, DatabaseValueConvertible { case top case middle case bottom diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a1ed006575..b83061452e 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -106,7 +106,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D // MARK: - Initialization - init( + public init( allowedInputTypes: MessageInputTypes, message: String? = nil, accessibility: Accessibility? = nil, @@ -159,8 +159,8 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let contactLastKnownClientVersion: FeatureVersion? public let threadDisplayPictureUrl: String? public let contactProfile: Profile? - internal let closedGroupProfileFront: Profile? - internal let closedGroupProfileBack: Profile? + public let closedGroupProfileFront: Profile? + public let closedGroupProfileBack: Profile? internal let closedGroupProfileBackFallback: Profile? public let closedGroupAdminProfile: Profile? public let closedGroupName: String? @@ -267,33 +267,6 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D return Date(timeIntervalSince1970: TimeInterval(Double(interactionTimestampMs) / 1000)) } - public var messageInputState: MessageInputState { - guard !threadIsNoteToSelf else { return MessageInputState(allowedInputTypes: .all) } - guard threadIsBlocked != true else { - return MessageInputState( - allowedInputTypes: .none, - message: "blockBlockedDescription".localized(), - messageAccessibility: Accessibility( - identifier: "Blocked banner" - ) - ) - } - - if threadVariant == .community && threadCanWrite == false { - return MessageInputState( - allowedInputTypes: .none, - message: "permissionsWriteCommunity".localized() - ) - } - - return MessageInputState( - allowedInputTypes: (threadRequiresApproval == false && threadIsMessageRequest == false ? - .all : - .textOnly - ) - ) - } - public var userCount: Int? { switch threadVariant { case .contact: return nil @@ -382,16 +355,21 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D } /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read - public func markAsRead(target: ReadTarget, using dependencies: Dependencies) { - // Store the logic to mark a thread as read (to paths need to run this) - let threadId: String = self.threadId - let threadWasMarkedUnread: Bool? = self.threadWasMarkedUnread - let markThreadAsReadIfNeeded: (Dependencies) -> () = { dependencies in - // Only make this change if needed (want to avoid triggering a thread update - // if not needed) - guard threadWasMarkedUnread == true else { return } + public func markAsRead(target: ReadTarget, using dependencies: Dependencies) async throws { + let shouldMarkThreadAsUnread: Bool = (self.threadWasMarkedUnread == true) + let targetInteractionId: Int64? = { + guard case .threadAndInteractions(let interactionId) = target else { return nil } + guard threadHasUnreadMessagesOfAnyKind == true else { return nil } - dependencies[singleton: .storage].writeAsync { db in + return (interactionId ?? self.interactionId) + }() + + /// No need to do anything if the thread is already marked as read and we don't have a target interaction + guard shouldMarkThreadAsUnread || targetInteractionId != nil else { return } + + /// Perform the updates + try await dependencies[singleton: .storage].writeAsync { db in + if shouldMarkThreadAsUnread { try SessionThread .filter(id: threadId) .updateAllAndConfig( @@ -401,56 +379,32 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(false))) } - } - - // Determine what we want to mark as read - switch target { - // Only mark the thread as read - case .thread: markThreadAsReadIfNeeded(dependencies) - // We want to mark both the thread and interactions as read - case .threadAndInteractions(let interactionId): - guard - self.threadHasUnreadMessagesOfAnyKind == true, - let targetInteractionId: Int64 = (interactionId ?? self.interactionId) - else { - // No unread interactions so just mark the thread as read if needed - markThreadAsReadIfNeeded(dependencies) - return - } - - let threadId: String = self.threadId - let threadVariant: SessionThread.Variant = self.threadVariant - let threadIsBlocked: Bool? = self.threadIsBlocked - let threadIsMessageRequest: Bool? = self.threadIsMessageRequest - - dependencies[singleton: .storage].writeAsync { db in - markThreadAsReadIfNeeded(dependencies) - - try Interaction.markAsRead( - db, - interactionId: targetInteractionId, + if let interactionId: Int64 = targetInteractionId { + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: threadId, + threadVariant: threadVariant, + includingOlder: true, + trySendReadReceipt: SessionThread.canSendReadReceipt( threadId: threadId, threadVariant: threadVariant, - includingOlder: true, - trySendReadReceipt: SessionThread.canSendReadReceipt( - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ), using: dependencies - ) - } + ), + using: dependencies + ) + } } } /// This method will mark a thread as read - public func markAsUnread(using dependencies: Dependencies) { + public func markAsUnread(using dependencies: Dependencies) async throws { guard self.threadWasMarkedUnread != true else { return } let threadId: String = self.threadId - dependencies[singleton: .storage].writeAsync { db in + try await dependencies[singleton: .storage].writeAsync { db in try SessionThread .filter(id: threadId) .updateAllAndConfig( @@ -791,25 +745,53 @@ public extension SessionThreadViewModel { let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() + let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `contactProfile` entry below otherwise the query will fail to parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 15 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 13 // The attachment info columns will be combined + let numColumnsBeforeProfiles: Int = 21 + let numColumnsBetweenProfilesAndAttachmentInfo: Int = 19 // The attachment info columns will be combined let request: SQLRequest = """ SELECT \(thread[.rowId]) AS \(ViewModel.Columns.rowId), \(thread[.id]) AS \(ViewModel.Columns.threadId), \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - + (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), + ( + SELECT \(contactProfile[.id]) + FROM \(contactProfile.self) + LEFT JOIN \(contact.self) ON \(contactProfile[.id]) = \(contact[.id]) + LEFT JOIN \(groupMember.self) ON \(groupMember[.groupId]) = \(thread[.id]) + WHERE ( + (\(groupMember[.profileId]) = \(contactProfile[.id]) OR + \(contact[.id]) = \(thread[.id])) AND + \(contact[.id]) <> \(userSessionId.hexString) AND + \(contact[.lastKnownClientVersion]) = \(FeatureVersion.legacyDisappearingMessages) + ) + ) AS \(ViewModel.Columns.outdatedMemberId), + ( + COALESCE(\(closedGroup[.invited]), false) = true OR ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) + ) AS \(ViewModel.Columns.threadIsMessageRequest), + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + IFNULL(\(contact[.didApproveMe]), false) = false + ) AS \(ViewModel.Columns.threadRequiresApproval), + \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), + \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), + \(thread[.isDraft]) AS \(ViewModel.Columns.threadIsDraft), ( COALESCE(\(closedGroup[.invited]), false) = true OR ( \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND @@ -818,7 +800,7 @@ public extension SessionThreadViewModel { ) ) AS \(ViewModel.Columns.threadIsMessageRequest), - (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.Columns.threadContactIsTyping), + false AS \(ViewModel.Columns.threadContactIsTyping), \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), \(aggregateInteraction[.threadUnreadCount]), \(aggregateInteraction[.threadUnreadMentionCount]), @@ -830,6 +812,7 @@ public extension SessionThreadViewModel { \(closedGroupProfileBackFallback.allColumns), \(closedGroupAdminProfile.allColumns), \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), + \(closedGroupUserCount[.closedGroupUserCount]), \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), EXISTS ( @@ -861,6 +844,11 @@ public extension SessionThreadViewModel { ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), + \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), + \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), + \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), + \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), + \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), COALESCE( \(openGroup[.displayPictureOriginalUrl]), @@ -890,7 +878,6 @@ public extension SessionThreadViewModel { FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) LEFT JOIN ( SELECT @@ -969,6 +956,15 @@ public extension SessionThreadViewModel { ) ) ) + + LEFT JOIN ( + SELECT + \(groupMember[.groupId]), + COUNT(DISTINCT \(groupMember[.profileId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) + FROM \(GroupMember.self) + WHERE \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) + GROUP BY \(groupMember[.groupId]) + ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(closedGroup[.threadId])")) WHERE \(thread[.id]) IN \(ids) \(groupSQL) diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 60c6bf85cc..ed4e5e4eaf 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -330,23 +330,13 @@ public final class AttachmentManager: Sendable, ThumbnailManager { using: dependencies ) - // Process audio attachments - if pendingAttachment.utType.isAudio { - return (pendingAttachment.duration > 0, pendingAttachment.duration) - } - - // Process image attachments - if pendingAttachment.utType.isImage || pendingAttachment.utType.isAnimated { - return (pendingAttachment.isValidVisualMedia, nil) + // Process audio and video attachments + if pendingAttachment.utType.isAudio || pendingAttachment.utType.isVideo { + return (pendingAttachment.isValid, pendingAttachment.duration) } - // Process video attachments - if pendingAttachment.utType.isVideo { - return (pendingAttachment.isValidVisualMedia, pendingAttachment.duration) - } - - // Any other attachment types are valid and have no duration - return (true, nil) + // No other attachments should have duration information + return (pendingAttachment.isValid, nil) } } @@ -1297,7 +1287,7 @@ public extension PendingAttachment { height: imageSize.map { UInt(floor($0.height)) }, duration: duration, isVisualMedia: utType.isVisualMedia, - isValid: isValidVisualMedia, + isValid: isValid, encryptionKey: encryptionKey, digest: digest ) @@ -1319,6 +1309,21 @@ public extension PendingAttachment { return fileExtension.filteredFilename } + var isValid: Bool { + // Process audio attachments + if utType.isAudio { + return (duration > 0) + } + + // Process image and video attachments + if utType.isImage || utType.isAnimated || utType.isVideo { + return isValidVisualMedia + } + + // Any other attachment types are valid and have no duration + return true + } + var isValidVisualMedia: Bool { guard utType.isImage || utType.isAnimated || utType.isVideo else { return false } guard case .media(let mediaMetadata) = metadata else { return false } diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index d51fb9fd78..3a6abf647c 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -104,14 +104,14 @@ public extension Authentication { static func with( _ db: ObservingDatabase, server: String, - activeOnly: Bool = true, + activelyPollingOnly: Bool = true, forceBlinded: Bool = false, using dependencies: Dependencies ) throws -> AuthenticationMethod { guard // TODO: [Database Relocation] Store capability info locally in libSession so we don't need the db here let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, server: server, activeOnly: activeOnly) + .fetchOne(db, server: server, activelyPollingOnly: activelyPollingOnly) else { throw CryptoError.invalidAuthentication } return Authentication.community(info: info, forceBlinded: forceBlinded) diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index bb5539072a..1e92f85a77 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -45,7 +45,6 @@ public class DeviceSleepManager: NSObject { fileprivate init(using dependencies: Dependencies) { self.dependencies = dependencies - DeviceSleepManager_objc.dependencies = dependencies super.init() @@ -90,18 +89,3 @@ public class DeviceSleepManager: NSObject { dependencies[singleton: .appContext].ensureSleepBlocking(shouldBlock, blockingObjects: blocks) } } - -// MARK: - Objective-C Support - -@objc(DeviceSleepManager_objc) -public class DeviceSleepManager_objc: NSObject { - fileprivate static var dependencies: Dependencies! - - @objc public static func addBlock(blockObject: NSObject?) { - dependencies[singleton: .deviceSleepManager].addBlock(blockObject: blockObject) - } - - @objc public static func removeBlock(blockObject: NSObject?) { - dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: blockObject) - } -} diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 20d21861d1..15d58531f0 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -153,20 +153,23 @@ public class DisplayPictureManager { .throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) .sink( receiveValue: { [dependencies] _ in - let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in - let result: Set = cache.downloadsToSchedule + let pendingInfo: Set = dependencies.mutate(cache: .displayPicture) { cache in + let result: Set = cache.downloadsToSchedule cache.downloadsToSchedule.removeAll() return result } dependencies[singleton: .storage].writeAsync { db in - pendingInfo.forEach { owner in + pendingInfo.forEach { info in dependencies[singleton: .jobRunner].add( db, job: Job( variant: .displayPictureDownload, shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details(owner: owner) + details: DisplayPictureDownloadJob.Details( + target: info.target, + timestamp: info.timestamp + ) ), canStartJob: true ) @@ -176,11 +179,9 @@ public class DisplayPictureManager { ) } - public func scheduleDownload(for owner: Owner) { - guard owner.canDownloadImage else { return } - + public func scheduleDownload(for target: DisplayPictureDownloadJob.Target, timestamp: TimeInterval? = nil) { dependencies.mutate(cache: .displayPicture) { cache in - cache.downloadsToSchedule.insert(owner) + cache.downloadsToSchedule.insert(TargetWithTimestamp(target: target, timestamp: timestamp)) } scheduleDownloads.send(()) } @@ -421,29 +422,12 @@ public class DisplayPictureManager { } } -// MARK: - DisplayPictureManager.Owner +// MARK: - Convenience public extension DisplayPictureManager { - enum OwnerId: Hashable { - case user(String) - case group(String) - case community(String) - } - - enum Owner: Hashable { - case user(Profile) - case group(ClosedGroup) - case community(OpenGroup) - case file(String) - - var canDownloadImage: Bool { - switch self { - case .user(let profile): return (profile.displayPictureUrl?.isEmpty == false) - case .group(let group): return (group.displayPictureUrl?.isEmpty == false) - case .community(let openGroup): return (openGroup.imageId?.isEmpty == false) - case .file: return false - } - } + struct TargetWithTimestamp: Hashable { + let target: DisplayPictureDownloadJob.Target + let timestamp: TimeInterval? } } @@ -451,7 +435,7 @@ public extension DisplayPictureManager { public extension DisplayPictureManager { class Cache: DisplayPictureCacheType { - public var downloadsToSchedule: Set = [] + public var downloadsToSchedule: Set = [] } } @@ -468,9 +452,9 @@ public extension Cache { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol DisplayPictureImmutableCacheType: ImmutableCacheType { - var downloadsToSchedule: Set { get } + var downloadsToSchedule: Set { get } } public protocol DisplayPictureCacheType: DisplayPictureImmutableCacheType, MutableCacheType { - var downloadsToSchedule: Set { get set } + var downloadsToSchedule: Set { get set } } diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h deleted file mode 100644 index 759086b5e3..0000000000 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.h +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class OWSAudioPlayer; - -typedef NS_ENUM(NSInteger, AudioPlaybackState) { - AudioPlaybackState_Stopped, - AudioPlaybackState_Playing, - AudioPlaybackState_Paused, -}; - -@protocol OWSAudioPlayerDelegate - -- (AudioPlaybackState)audioPlaybackState; -- (void)setAudioPlaybackState:(AudioPlaybackState)state; -- (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration; -- (void)showInvalidAudioFileAlert; -- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag; - -@end - -#pragma mark - - -typedef NS_ENUM(NSUInteger, OWSAudioBehavior) { - OWSAudioBehavior_Unknown, - OWSAudioBehavior_Playback, - OWSAudioBehavior_AudioMessagePlayback, - OWSAudioBehavior_PlayAndRecord, - OWSAudioBehavior_Call, -}; - -@interface OWSAudioPlayer : NSObject - -@property (nonatomic, weak) id delegate; -// This property can be used to associate instances of the player with view or model objects. -@property (nonatomic, weak) id owner; -@property (nonatomic) BOOL isLooping; -@property (nonatomic) BOOL isPlaying; -@property (nonatomic) float playbackRate; -@property (nonatomic) NSTimeInterval duration; - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior; -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior delegate:(nullable id)delegate; -- (void)play; -- (void)setCurrentTime:(NSTimeInterval)currentTime; -- (void)pause; -- (void)stop; -- (void)togglePlayState; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.m b/SessionMessagingKit/Utilities/OWSAudioPlayer.m deleted file mode 100644 index 4e785aeb16..0000000000 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ /dev/null @@ -1,233 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSAudioPlayer.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// A no-op delegate implementation to be used when we don't need a delegate. -@interface OWSAudioPlayerDelegateStub : NSObject - -@property (nonatomic) AudioPlaybackState audioPlaybackState; - -@end - -#pragma mark - - -@implementation OWSAudioPlayerDelegateStub - -- (void)setAudioProgress:(CGFloat)progress duration:(CGFloat)duration -{ - // Do nothing -} - -- (void)showInvalidAudioFileAlert -{ - // Do nothing -} - -- (void)audioPlayerDidFinishPlaying:(OWSAudioPlayer *)player successfully:(BOOL)flag -{ - // Do nothing -} - -@end - -#pragma mark - - -@interface OWSAudioPlayer () - -@property (nonatomic, readonly) NSURL *mediaUrl; -@property (nonatomic, nullable) AVAudioPlayer *audioPlayer; -@property (nonatomic, nullable) NSTimer *audioPlayerPoller; -@property (nonatomic, readonly) OWSAudioActivity *audioActivity; - -@end - -#pragma mark - - -@implementation OWSAudioPlayer - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl - audioBehavior:(OWSAudioBehavior)audioBehavior -{ - return [self initWithMediaUrl:mediaUrl audioBehavior:audioBehavior delegate:[OWSAudioPlayerDelegateStub new]]; -} - -- (instancetype)initWithMediaUrl:(NSURL *)mediaUrl - audioBehavior:(OWSAudioBehavior)audioBehavior - delegate:(nullable id)delegate -{ - self = [super init]; - if (!self) { - return self; - } - - _mediaUrl = mediaUrl; - _delegate = delegate; - - // stringlint:ignore_start - NSString *audioActivityDescription = [NSString stringWithFormat:@"%@ %@", @"OWSAudioPlayer", self.mediaUrl]; - _audioActivity = [[OWSAudioActivity alloc] initWithAudioDescription:audioActivityDescription behavior:audioBehavior]; - // stringlint:ignore_stop - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:NSNotification.sessionDidEnterBackground - object:nil]; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; - - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; - - [self stop]; -} - -#pragma mark - Dependencies - -- (OWSAudioSession *)audioSession -{ - return SMKEnvironment.shared.audioSession; -} - -#pragma mark - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - [self stop]; -} - -#pragma mark - Methods - -- (BOOL)isPlaying -{ - return (self.delegate.audioPlaybackState == AudioPlaybackState_Playing); -} - -- (void)play -{ - // get current audio activity - [self playWithAudioActivity:self.audioActivity]; -} - -- (void)playWithAudioActivity:(OWSAudioActivity *)audioActivity -{ - [self.audioPlayerPoller invalidate]; - - self.delegate.audioPlaybackState = AudioPlaybackState_Playing; - - [[AVAudioSession sharedInstance] setCategory: AVAudioSessionCategoryPlayback error: nil]; - - if (!self.audioPlayer) { - NSError *error; - self.audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.mediaUrl error:&error]; - self.audioPlayer.enableRate = YES; - if (error) { - [self stop]; - - if ([error.domain isEqualToString:NSOSStatusErrorDomain] - && (error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile)) { - [self.delegate showInvalidAudioFileAlert]; - } - - return; - } - self.audioPlayer.delegate = self; - if (self.isLooping) { - self.audioPlayer.numberOfLoops = -1; - } - } - - [self.audioPlayer play]; - [self.audioPlayerPoller invalidate]; - - __weak OWSAudioPlayer *weakSelf = self; - self.audioPlayerPoller = [NSTimer weakScheduledTimerWithTimeInterval:.05f repeats:YES onFire:^(NSTimer * _Nonnull timer) { - [weakSelf audioPlayerUpdated:timer]; - }]; - - // Prevent device from sleeping while playing audio. - [DeviceSleepManager_objc addBlockWithBlockObject:self]; -} - -- (void)setCurrentTime:(NSTimeInterval)currentTime -{ - [self.audioPlayer setCurrentTime:currentTime]; -} - -- (float)getPlaybackRate -{ - return self.audioPlayer.rate; -} - -- (NSTimeInterval)duration -{ - return [self.audioPlayer duration]; -} - -- (void)setPlaybackRate:(float)rate -{ - [self.audioPlayer setRate:rate]; -} - -- (void)pause -{ - self.delegate.audioPlaybackState = AudioPlaybackState_Paused; - [self.audioPlayer pause]; - [self.audioPlayerPoller invalidate]; - [self.delegate setAudioProgress:(CGFloat)[self.audioPlayer currentTime] duration:(CGFloat)[self.audioPlayer duration]]; - - [self endAudioActivities]; - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; -} - -- (void)stop -{ - self.delegate.audioPlaybackState = AudioPlaybackState_Stopped; - [self.audioPlayer pause]; - [self.audioPlayerPoller invalidate]; - [self.delegate setAudioProgress:0 duration:0]; - - [self endAudioActivities]; - [DeviceSleepManager_objc removeBlockWithBlockObject:self]; -} - -- (void)endAudioActivities -{ - [self.audioSession endAudioActivity:self.audioActivity]; -} - -- (void)togglePlayState -{ - if (self.isPlaying) { - [self pause]; - } else { - [self playWithAudioActivity:self.audioActivity]; - } -} - -#pragma mark - Events - -- (void)audioPlayerUpdated:(NSTimer *)timer -{ - [self.delegate setAudioProgress:(CGFloat)[self.audioPlayer currentTime] duration:(CGFloat)[self.audioPlayer duration]]; -} - -- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag -{ - [self stop]; - [self.delegate audioPlayerDidFinishPlaying:self successfully:flag]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSAudioPlayer.swift b/SessionMessagingKit/Utilities/OWSAudioPlayer.swift new file mode 100644 index 0000000000..7af1f50ee8 --- /dev/null +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.swift @@ -0,0 +1,257 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import AVFoundation +import SessionUtilitiesKit + +// MARK: - AudioPlaybackState + +public enum AudioPlaybackState: Int { + case stopped + case playing + case paused +} + +// MARK: - OWSAudioBehavior + +public enum OWSAudioBehavior: UInt, Equatable { + case unknown + case playback + case audioMessagePlayback + case playAndRecord + case call +} + +// MARK: - OWSAudioPlayerDelegate Protocol + +public protocol OWSAudioPlayerDelegate: AnyObject { + @MainActor var audioPlaybackState: AudioPlaybackState { get set } + + @MainActor func setAudioProgress(_ progress: CGFloat, duration: CGFloat) + @MainActor func showInvalidAudioFileAlert() + @MainActor func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) +} + +// MARK: - OWSAudioPlayerDelegateStub + +/// A no-op delegate implementation to be used when we don't need a delegate. +class OWSAudioPlayerDelegateStub: OWSAudioPlayerDelegate { + var audioPlaybackState: AudioPlaybackState = .stopped + + func setAudioProgress(_ progress: CGFloat, duration: CGFloat) { + // Do nothing + } + + func showInvalidAudioFileAlert() { + // Do nothing + } + + func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) { + // Do nothing + } +} + +// MARK: - OWSAudioPlayer + +public class OWSAudioPlayer: NSObject { + + // MARK: - Properties + + private let dependencies: Dependencies + private let mediaUrl: URL + @MainActor private var audioPlayer: AVAudioPlayer? + private var audioPlayerPoller: Timer? + private let audioActivity: AudioActivity + + public weak var delegate: OWSAudioPlayerDelegate? + @MainActor public var isLooping: Bool = false + + @MainActor public var isPlaying: Bool { + return delegate?.audioPlaybackState == .playing + } + + @MainActor public var currentTime: TimeInterval { + get { audioPlayer?.currentTime ?? 0 } + set { audioPlayer?.currentTime = newValue } + } + + @MainActor public var playbackRate: Float { + get { audioPlayer?.rate ?? 1.0 } + set { audioPlayer?.rate = newValue } + } + + @MainActor public var duration: TimeInterval { + return audioPlayer?.duration ?? 0 + } + + // MARK: - Initialization + + public convenience init( + mediaUrl: URL, + audioBehavior: OWSAudioBehavior, + using dependencies: Dependencies + ) { + self.init( + mediaUrl: mediaUrl, + audioBehavior: audioBehavior, + delegate: OWSAudioPlayerDelegateStub(), + using: dependencies + ) + } + + public init( + mediaUrl: URL, + audioBehavior: OWSAudioBehavior, + delegate: OWSAudioPlayerDelegate?, + using dependencies: Dependencies + ) { + self.dependencies = dependencies + self.mediaUrl = mediaUrl + self.delegate = delegate + self.audioActivity = AudioActivity( + audioDescription: "OWSAudioPlayer \(mediaUrl)", // stringlint:ignore + behavior: audioBehavior, + using: dependencies + ) + + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground(_:)), + name: .sessionDidEnterBackground, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + + Task { @MainActor [delegate, audioPlayer, audioPlayerPoller, audioActivity, dependencies] in + delegate?.audioPlaybackState = .stopped + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + delegate?.setAudioProgress(0, duration: 0) + dependencies[singleton: .audioSession].endAudioActivity(audioActivity) + } + } + + // MARK: - Notification Handlers + + @objc private func applicationDidEnterBackground(_ notification: Notification) { + Task { @MainActor in + stop() + } + } + + // MARK: - Public Methods + + @MainActor public func play() { + playWithAudioActivity(audioActivity) + } + + @MainActor public func playWithAudioActivity(_ audioActivity: AudioActivity) { + audioPlayerPoller?.invalidate() + + delegate?.audioPlaybackState = .playing + + try? AVAudioSession.sharedInstance().setCategory(.playback) + + if audioPlayer == nil { + do { + let player = try AVAudioPlayer(contentsOf: mediaUrl as URL) + player.enableRate = true + player.delegate = self + + if isLooping { + player.numberOfLoops = -1 + } + + self.audioPlayer = player + } catch { + stop() + + let nsError = error as NSError + if nsError.domain == NSOSStatusErrorDomain && + (nsError.code == Int(kAudioFileInvalidFileError) || + nsError.code == Int(kAudioFileStreamError_InvalidFile)) { + delegate?.showInvalidAudioFileAlert() + } + + return + } + } + + audioPlayer?.play() + audioPlayerPoller?.invalidate() + + + audioPlayerPoller = Timer.scheduledTimerOnMainThread( + withTimeInterval: 0.05, + repeats: true, + using: dependencies + ) { [weak self] timer in + self?.audioPlayerUpdated(timer) + } + + // Prevent device from sleeping while playing audio + dependencies[singleton: .deviceSleepManager].addBlock(blockObject: self) + } + + @MainActor public func pause() { + delegate?.audioPlaybackState = .paused + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + + if let player = audioPlayer { + delegate?.setAudioProgress(CGFloat(player.currentTime), duration: CGFloat(player.duration)) + } + + endAudioActivities() + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + } + + @MainActor public func stop() { + delegate?.audioPlaybackState = .stopped + audioPlayer?.pause() + audioPlayerPoller?.invalidate() + delegate?.setAudioProgress(0, duration: 0) + + endAudioActivities() + dependencies[singleton: .deviceSleepManager].removeBlock(blockObject: self) + } + + @MainActor public func togglePlayState() { + if isPlaying { + pause() + } else { + playWithAudioActivity(audioActivity) + } + } + + // MARK: - Private Methods + + private func endAudioActivities() { + dependencies[singleton: .audioSession].endAudioActivity(audioActivity) + } + + @objc private func audioPlayerUpdated(_ timer: Timer) { + Task { @MainActor in + if let player = audioPlayer { + delegate?.setAudioProgress(CGFloat(player.currentTime), duration: CGFloat(player.duration)) + } + } + } +} + +// MARK: - AVAudioPlayerDelegate + +extension OWSAudioPlayer: AVAudioPlayerDelegate { + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Task { @MainActor in + stop() + delegate?.audioPlayerDidFinishPlaying(self, successfully: flag) + } + } +} diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index dcc6c3eea3..4e25efe90f 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -4,33 +4,50 @@ import Foundation import AVFoundation import SessionUtilitiesKit -@objc(OWSAudioActivity) -public class AudioActivity: NSObject { - let audioDescription: String +// MARK: - Singleton + +public extension Singleton { + static let audioSession: SingletonConfig = Dependencies.create( + identifier: "audioSession", + createInstance: { _ in OWSAudioSession() } + ) +} +// MARK: - AudioActivity + +public class AudioActivity: Equatable, CustomStringConvertible { + let dependencies: Dependencies + let audioDescription: String let behavior: OWSAudioBehavior - @objc - public init(audioDescription: String, behavior: OWSAudioBehavior) { + public init(audioDescription: String, behavior: OWSAudioBehavior, using dependencies: Dependencies) { self.audioDescription = audioDescription self.behavior = behavior + self.dependencies = dependencies } deinit { - SessionEnvironment.shared?.audioSession.ensureAudioSessionActivationStateAfterDelay() + dependencies[singleton: .audioSession].ensureAudioSessionActivationStateAfterDelay() } // MARK: - override public var description: String { + public var description: String { return "<[AudioActivity] audioDescription: \"\(audioDescription)\">" // stringlint:ignore } + + public static func ==(lhs: AudioActivity, rhs: AudioActivity) -> Bool { + return ( + lhs.audioDescription == rhs.audioDescription && + lhs.behavior == rhs.behavior + ) + } } -@objc -public class OWSAudioSession: NSObject { +// MARK: - OWSAudioSession + +public class OWSAudioSession { - @objc public func setup() { NotificationCenter.default.addObserver(self, selector: #selector(proximitySensorStateDidChange(notification:)), name: UIDevice.proximityStateDidChangeNotification, object: nil) } @@ -48,7 +65,6 @@ public class OWSAudioSession: NSObject { return Set(self.currentActivities.compactMap { $0.value?.behavior }) } - @objc public func startAudioActivity(_ audioActivity: AudioActivity) -> Bool { Log.debug("[AudioActivity] startAudioActivity called with \(audioActivity)") @@ -66,7 +82,6 @@ public class OWSAudioSession: NSObject { } } - @objc public func endAudioActivity(_ audioActivity: AudioActivity) { Log.debug("[AudioActivity] endAudioActivity called with: \(audioActivity)") diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 969a79da3b..ab5571aaef 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -75,6 +75,9 @@ public extension ObservableKey { static func attachmentDeleted(id: String, messageId: Int64?) -> ObservableKey { ObservableKey("attachmentDeleted-\(id)-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentDeleted) } + static func reactionsChanged(messageId: Int64) -> ObservableKey { + ObservableKey("reactionsChanged-\(messageId)", .reactionsChanged) + } // MARK: - Message Requests @@ -82,6 +85,18 @@ public extension ObservableKey { static let messageRequestDeleted: ObservableKey = "messageRequestDeleted" static let messageRequestMessageRead: ObservableKey = "messageRequestMessageRead" static let messageRequestUnreadMessageReceived: ObservableKey = "messageRequestUnreadMessageReceived" + + // MARK: - Groups + + static func groupMemberCreated(threadId: String) -> ObservableKey { + ObservableKey("groupMemberCreated-\(threadId)", .groupMemberCreated) + } + static func groupMemberUpdated(profileId: String, threadId: String) -> ObservableKey { + ObservableKey("groupMemberUpdated-\(threadId)-\(profileId)", .groupMemberUpdated) + } + static func groupMemberDeleted(profileId: String, threadId: String) -> ObservableKey { + ObservableKey("groupMemberDeleted-\(threadId)-\(profileId)", .groupMemberDeleted) + } } public extension GenericObservableKey { @@ -102,6 +117,11 @@ public extension GenericObservableKey { static let attachmentCreated: GenericObservableKey = "attachmentCreated" static let attachmentUpdated: GenericObservableKey = "attachmentUpdated" static let attachmentDeleted: GenericObservableKey = "attachmentDeleted" + static let reactionsChanged: GenericObservableKey = "reactionsChanged" + + static let groupMemberCreated: GenericObservableKey = "groupMemberCreated" + static let groupMemberUpdated: GenericObservableKey = "groupMemberUpdated" + static let groupMemberDeleted: GenericObservableKey = "groupMemberDeleted" } // MARK: - Event Payloads - General @@ -124,12 +144,18 @@ public struct LoadPageEvent: Hashable { public enum Target: Hashable { case initial + case initialPageAround(AnyHashable) case previousPage(Int) case nextPage(Int) + case jumpTo(AnyHashable, Int) } public static var initial: LoadPageEvent { LoadPageEvent(target: .initial) } + public static func initialPageAround(id: ID) -> LoadPageEvent { + LoadPageEvent(target: .initialPageAround(id)) + } + public static func previousPage(firstIndex: Int) -> LoadPageEvent { LoadPageEvent(target: .previousPage(firstIndex)) } @@ -137,6 +163,10 @@ public struct LoadPageEvent: Hashable { public static func nextPage(lastIndex: Int) -> LoadPageEvent { LoadPageEvent(target: .nextPage(lastIndex)) } + + public static func jumpTo(id: ID, padding: Int) -> LoadPageEvent { + LoadPageEvent(target: .jumpTo(id, padding)) + } } public struct UpdateSelectionEvent: Hashable { @@ -159,15 +189,6 @@ public struct TypingIndicatorEvent: Hashable { } } -public extension ObservingDatabase { - func addTypingIndicatorEvent(threadId: String, change: TypingIndicatorEvent.Change) { - self.addEvent(ObservedEvent( - key: .typingIndicator(threadId), - value: TypingIndicatorEvent(threadId: threadId, change: change) - )) - } -} - // MARK: - Event Payloads - Contacts public struct ProfileEvent: Hashable { @@ -196,6 +217,7 @@ public struct ContactEvent: Hashable { case isApproved(Bool) case isBlocked(Bool) case didApproveMe(Bool) + case unblinded(blindedId: String, unblindedId: String) } } @@ -229,7 +251,8 @@ public struct ConversationEvent: Hashable { case mutedUntilTimestamp(TimeInterval?) case onlyNotifyForMentions(Bool) case markedAsUnread(Bool) - case unreadCountChanged + case unreadCount + case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) } } @@ -256,6 +279,7 @@ public struct MessageEvent: Hashable { case wasRead(Bool) case state(Interaction.State) case recipientReadTimestampMs(Int64) + case markedAsDeleted } } @@ -298,3 +322,44 @@ public extension ObservingDatabase { } } } + +public struct ReactionEvent: Hashable { + public let id: Int64 + public let messageId: Int64 + public let change: Change + + public enum Change: Hashable { + case added(String) + case removed(String) + } +} + +public extension ObservingDatabase { + func addReactionEvent(id: Int64, messageId: Int64, change: ReactionEvent.Change) { + let event: ReactionEvent = ReactionEvent(id: id, messageId: messageId, change: change) + + addEvent(ObservedEvent(key: .reactionsChanged(messageId: messageId), value: event)) + } +} + +public struct GroupMemberEvent: Hashable { + public let profileId: String + public let threadId: String + public let change: Change? + + public enum Change: Hashable { + case role(role: GroupMember.Role, status: GroupMember.RoleStatus) + } +} + +public extension ObservingDatabase { + func addGroupMemberEvent(profileId: String, threadId: String, type: CRUDEvent) { + let event: GroupMemberEvent = GroupMemberEvent(profileId: profileId, threadId: threadId, change: type.change) + + switch type { + case .created: addEvent(ObservedEvent(key: .groupMemberCreated(threadId: threadId), value: event)) + case .updated: addEvent(ObservedEvent(key: .groupMemberUpdated(profileId: profileId, threadId: threadId), value: event)) + case .deleted: addEvent(ObservedEvent(key: .groupMemberDeleted(profileId: profileId, threadId: threadId), value: event)) + } + } +} diff --git a/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift index 9099cfe529..ffc13ba689 100644 --- a/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift +++ b/SessionMessagingKit/Utilities/ObservableKeyEvent+Utilities.swift @@ -7,6 +7,11 @@ public extension LoadPageEvent { func target(with current: PagedData.LoadResult) -> PagedData.Target? { switch target { case .initial: return .initial + case .initialPageAround(let erasedId): + guard let id: ID = erasedId as? ID else { return .initial } + + return .initialPageAround(id: id) + case .nextPage(let lastIndex): guard lastIndex == current.info.lastIndex else { return nil } @@ -16,6 +21,14 @@ public extension LoadPageEvent { guard firstIndex == current.info.firstPageOffset else { return nil } return .pageBefore + + case .jumpTo(let erasedId, let padding): + guard + let id: ID = erasedId as? ID, + !current.info.currentIds.contains(id) + else { return nil } + + return .jumpTo(id: id, padding: padding) } } } diff --git a/SessionMessagingKit/Utilities/Preferences+Sound.swift b/SessionMessagingKit/Utilities/Preferences+Sound.swift index 566d82414d..5a4dcd3e38 100644 --- a/SessionMessagingKit/Utilities/Preferences+Sound.swift +++ b/SessionMessagingKit/Utilities/Preferences+Sound.swift @@ -200,10 +200,18 @@ public extension Preferences { // MARK: - AudioPlayer - public static func audioPlayer(for sound: Sound, behavior: OWSAudioBehavior) -> OWSAudioPlayer? { + @MainActor public static func audioPlayer( + for sound: Sound, + behavior: OWSAudioBehavior, + using dependencies: Dependencies + ) -> OWSAudioPlayer? { guard let soundUrl: URL = sound.soundUrl(quiet: false) else { return nil } - let player = OWSAudioPlayer(mediaUrl: soundUrl, audioBehavior: behavior) + let player: OWSAudioPlayer = OWSAudioPlayer( + mediaUrl: soundUrl, + audioBehavior: behavior, + using: dependencies + ) // These two cases should loop if sound == .callConnecting || sound == .callOutboundRinging { diff --git a/SessionMessagingKit/Utilities/SessionEnvironment.swift b/SessionMessagingKit/Utilities/SessionEnvironment.swift index 0ed21dd9d4..d29fd4f44b 100644 --- a/SessionMessagingKit/Utilities/SessionEnvironment.swift +++ b/SessionMessagingKit/Utilities/SessionEnvironment.swift @@ -6,7 +6,6 @@ import SessionUtilitiesKit public class SessionEnvironment { public static var shared: SessionEnvironment? - public let audioSession: OWSAudioSession public let proximityMonitoringManager: OWSProximityMonitoringManager public let windowManager: OWSWindowManager public var isRequestingPermission: Bool @@ -14,11 +13,9 @@ public class SessionEnvironment { // MARK: - Initialization public init( - audioSession: OWSAudioSession, proximityMonitoringManager: OWSProximityMonitoringManager, windowManager: OWSWindowManager ) { - self.audioSession = audioSession self.proximityMonitoringManager = proximityMonitoringManager self.windowManager = windowManager self.isRequestingPermission = false @@ -41,6 +38,5 @@ public class SessionEnvironment { public class SMKEnvironment: NSObject { @objc public static let shared: SMKEnvironment = SMKEnvironment() - @objc public var audioSession: OWSAudioSession? { SessionEnvironment.shared?.audioSession } @objc public var windowManager: OWSWindowManager? { SessionEnvironment.shared?.windowManager } } diff --git a/SessionNetworkingKit/SOGS/Models/Room.swift b/SessionNetworkingKit/SOGS/Models/Room.swift index 6f188c5ce7..2e310b7330 100644 --- a/SessionNetworkingKit/SOGS/Models/Room.swift +++ b/SessionNetworkingKit/SOGS/Models/Room.swift @@ -1,9 +1,10 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension Network.SOGS { - public struct Room: Codable, Equatable { + public struct Room: Sendable, Codable, Equatable { enum CodingKeys: String, CodingKey { case token case name @@ -141,6 +142,96 @@ extension Network.SOGS { /// /// It is included in the response only if the requesting user has moderator or admin permissions public let defaultUpload: Bool? + + public init( + token: String, + name: String, + roomDescription: String?, + infoUpdates: Int64, + messageSequence: Int64, + created: TimeInterval, + activeUsers: Int64, + activeUsersCutoff: Int64, + imageId: String?, + pinnedMessages: [PinnedMessage]?, + admin: Bool, + globalAdmin: Bool, + admins: [String], + hiddenAdmins: [String]?, + moderator: Bool, + globalModerator: Bool, + moderators: [String], + hiddenModerators: [String]?, + read: Bool, + defaultRead: Bool?, + defaultAccessible: Bool?, + write: Bool, + defaultWrite: Bool?, + upload: Bool, + defaultUpload: Bool? + ) { + self.token = token + self.name = name + self.roomDescription = roomDescription + self.infoUpdates = infoUpdates + self.messageSequence = messageSequence + self.created = created + self.activeUsers = activeUsers + self.activeUsersCutoff = activeUsersCutoff + self.imageId = imageId + self.pinnedMessages = pinnedMessages + self.admin = admin + self.globalAdmin = globalAdmin + self.admins = admins + self.hiddenAdmins = hiddenAdmins + self.moderator = moderator + self.globalModerator = globalModerator + self.moderators = moderators + self.hiddenModerators = hiddenModerators + self.read = read + self.defaultRead = defaultRead + self.defaultAccessible = defaultAccessible + self.write = write + self.defaultWrite = defaultWrite + self.upload = upload + self.defaultUpload = defaultUpload + } + } +} + +// MARK: - Convenience + +public extension Network.SOGS.Room { + func with( + messageSequence: Update = .useExisting + ) -> Network.SOGS.Room { + return Network.SOGS.Room( + token: token, + name: name, + roomDescription: roomDescription, + infoUpdates: infoUpdates, + messageSequence: messageSequence.or(self.messageSequence), + created: created, + activeUsers: activeUsers, + activeUsersCutoff: activeUsersCutoff, + imageId: imageId, + pinnedMessages: pinnedMessages, + admin: admin, + globalAdmin: globalAdmin, + admins: admins, + hiddenAdmins: hiddenAdmins, + moderator: moderator, + globalModerator: globalModerator, + moderators: moderators, + hiddenModerators: hiddenModerators, + read: read, + defaultRead: defaultRead, + defaultAccessible: defaultAccessible, + write: write, + defaultWrite: defaultWrite, + upload: upload, + defaultUpload: defaultUpload + ) } } diff --git a/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift b/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift index 5b902a0941..3851ca2528 100644 --- a/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift +++ b/SessionNetworkingKit/SOGS/Models/SOGSMessage.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtilitiesKit extension Network.SOGS { - public struct Message: Codable, Equatable { + public struct Message: Codable, Equatable, Hashable { enum CodingKeys: String, CodingKey { case id case sender = "session_id" @@ -35,7 +35,7 @@ extension Network.SOGS { public let base64EncodedData: String? public let base64EncodedSignature: String? - public struct Reaction: Codable, Equatable { + public struct Reaction: Codable, Equatable, Hashable { enum CodingKeys: String, CodingKey { case count case reactors diff --git a/SessionNetworkingKit/SOGS/SOGS.swift b/SessionNetworkingKit/SOGS/SOGS.swift index 80299ad743..2482ef11df 100644 --- a/SessionNetworkingKit/SOGS/SOGS.swift +++ b/SessionNetworkingKit/SOGS/SOGS.swift @@ -3,12 +3,21 @@ // stringlint:disable import Foundation +import SessionUtilitiesKit public extension Network { enum SOGS { public static let legacyDefaultServerIP = "116.203.70.33" public static let defaultServer = "https://open.getsession.org" public static let defaultServerPublicKey = "a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238" + public static let defaultAuthMethod: AuthenticationMethod = Authentication.community( + roomToken: "", + server: defaultServer, + publicKey: defaultServerPublicKey, + hasCapabilities: false, + supportsBlinding: true, + forceBlinded: false + ) public static let validTimestampVarianceThreshold: TimeInterval = (6 * 60 * 60) internal static let maxInactivityPeriodForPolling: TimeInterval = (14 * 24 * 60 * 60) diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 2eb62febd2..316818658a 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -381,7 +381,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView if isSharingUrl, let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, - (try? interaction.linkPreview.isEmpty(db)) == true + (((try? Interaction + .linkPreview(url: interaction.linkPreviewUrl, timestampMs: interaction.timestampMs)? + .fetchCount(db)) ?? 0) == 0) { try LinkPreview( url: linkPreviewDraft.urlString, diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index 9dede2231b..ed2867848a 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -239,7 +239,8 @@ class DatabaseSpec: QuickSpec { "messagingKit.RenameAttachments", "messagingKit.AddProMessageFlag", "LastProfileUpdateTimestamp", - "RemoveQuoteUnusedColumnsAndForeignKeys" + "RemoveQuoteUnusedColumnsAndForeignKeys", + "DropUnneededColumnsAndTables" ])) } diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index a13d429b3e..bedefdbe93 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -29,8 +29,8 @@ public extension NSAttributedString.Key { // MARK: - ThemedAttributedString -public class ThemedAttributedString: Equatable, Hashable { - internal var value: NSMutableAttributedString { +public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hashable { + internal var value: NSAttributedString { if let image = imageAttachmentGenerator?() { let attachment = NSTextAttachment(image: image) if let font = imageAttachmentReferenceFont { @@ -42,48 +42,67 @@ public class ThemedAttributedString: Equatable, Hashable { ) } - return NSMutableAttributedString(attachment: attachment) + return NSAttributedString(attachment: attachment) } return attributedString } public var string: String { value.string } public var length: Int { value.length } - internal var imageAttachmentGenerator: (() -> UIImage?)? - internal var imageAttachmentReferenceFont: UIFont? - internal var attributedString: NSMutableAttributedString + + /// `NSMutableAttributedString` is not `Sendable` so we need to manually manage access via an `NSLock` to ensure + /// thread safety + private let lock: NSLock = NSLock() + private let _attributedString: NSMutableAttributedString + + internal let imageAttachmentGenerator: (@Sendable () -> UIImage?)? + internal let imageAttachmentReferenceFont: UIFont? + internal var attributedString: NSAttributedString { + lock.lock() + defer { lock.unlock() } + return _attributedString + } public init() { - self.attributedString = NSMutableAttributedString() + self._attributedString = NSMutableAttributedString() + self.imageAttachmentGenerator = nil + self.imageAttachmentReferenceFont = nil } public init(attributedString: ThemedAttributedString) { - self.attributedString = attributedString.attributedString + self._attributedString = attributedString._attributedString self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator + self.imageAttachmentReferenceFont = attributedString.imageAttachmentReferenceFont } public init(attributedString: NSAttributedString) { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString = NSMutableAttributedString(attributedString: attributedString) + self._attributedString = NSMutableAttributedString(attributedString: attributedString) + self.imageAttachmentGenerator = nil + self.imageAttachmentReferenceFont = nil } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.attributedString = NSMutableAttributedString(string: string, attributes: attributes) + self._attributedString = NSMutableAttributedString(string: string, attributes: attributes) + self.imageAttachmentGenerator = nil + self.imageAttachmentReferenceFont = nil } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.attributedString = NSMutableAttributedString(attachment: attachment) + self._attributedString = NSMutableAttributedString(attachment: attachment) + self.imageAttachmentGenerator = nil + self.imageAttachmentReferenceFont = nil } - public init(imageAttachmentGenerator: @escaping (() -> UIImage?), referenceFont: UIFont?) { - self.attributedString = NSMutableAttributedString() + public init(imageAttachmentGenerator: @escaping (@Sendable () -> UIImage?), referenceFont: UIFont?) { + self._attributedString = NSMutableAttributedString() self.imageAttachmentGenerator = imageAttachmentGenerator self.imageAttachmentReferenceFont = referenceFont } @@ -110,7 +129,9 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributes ?? [:]) #endif - self.attributedString.append(NSAttributedString(string: string, attributes: attributes)) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(NSAttributedString(string: string, attributes: attributes)) return self } @@ -118,23 +139,31 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString.append(attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString) } public func append(_ attributedString: ThemedAttributedString) { - self.attributedString.append(attributedString.value) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString.value) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.attributedString.append(attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString) return self } public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { - self.attributedString.append(attributedString.value) + lock.lock() + defer { lock.unlock() } + self._attributedString.append(attributedString.value) return self } @@ -143,7 +172,9 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttribute(name, value: attrValue, range: targetRange) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttribute(name, value: attrValue, range: targetRange) } public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { @@ -151,7 +182,9 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttribute(name, value: attrValue, range: targetRange) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttribute(name, value: attrValue, range: targetRange) return self } @@ -160,7 +193,9 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttributes(attrs, range: targetRange) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttributes(attrs, range: targetRange) } public func addingAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) -> ThemedAttributedString { @@ -168,7 +203,9 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - self.attributedString.addAttributes(attrs, range: targetRange) + lock.lock() + defer { lock.unlock() } + self._attributedString.addAttributes(attrs, range: targetRange) return self } @@ -177,7 +214,9 @@ public class ThemedAttributedString: Equatable, Hashable { } public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { - self.attributedString.replaceCharacters(in: range, with: attributedString) + lock.lock() + defer { lock.unlock() } + self._attributedString.replaceCharacters(in: range, with: attributedString) } // MARK: - Convenience diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 844df5a58d..077924dcd3 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -4,6 +4,8 @@ import Foundation import UIKit public enum MentionUtilities { + static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) + public enum MentionLocation { case incomingMessage case outgoingMessage @@ -13,20 +15,24 @@ public enum MentionUtilities { case styleFree } + public static func allPubkeys(in string: String) -> Set { + guard !string.isEmpty else { return [] } + + return Set(pubkeyRegex + .matches(in: string, range: NSRange(string.startIndex..., in: string)) + .compactMap { match in Range(match.range, in: string).map { String(string[$0]) } }) + } + public static func getMentions( in string: String, currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? ) -> (String, [(range: NSRange, profileId: String, isCurrentUser: Bool)]) { - guard - let regex: NSRegularExpression = try? NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) - else { return (string, []) } - var string = string var lastMatchEnd: Int = 0 var mentions: [(range: NSRange, profileId: String, isCurrentUser: Bool)] = [] - while let match: NSTextCheckingResult = regex.firstMatch( + while let match: NSTextCheckingResult = pubkeyRegex.firstMatch( in: string, options: .withoutAnchoringBounds, range: NSRange(location: lastMatchEnd, length: string.utf16.count - lastMatchEnd) diff --git a/SessionUtilitiesKit/Database/Types/FetchablePair.swift b/SessionUtilitiesKit/Database/Types/FetchablePair.swift new file mode 100644 index 0000000000..300f5b61df --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/FetchablePair.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public typealias FetchablePairConformance = (Sendable & Codable & Equatable & Hashable) + +public struct FetchablePair: FetchablePairConformance, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case first + case second + } + + public let first: First + public let second: Second +} diff --git a/SessionUtilitiesKit/Database/Types/PagedData.swift b/SessionUtilitiesKit/Database/Types/PagedData.swift index 694a064ea7..e637935a02 100644 --- a/SessionUtilitiesKit/Database/Types/PagedData.swift +++ b/SessionUtilitiesKit/Database/Types/PagedData.swift @@ -61,6 +61,23 @@ public extension PagedData { self.firstPageOffset = firstPageOffset self.currentIds = currentIds } + + public func with(filterSQL: SQL) -> LoadedInfo { + return LoadedInfo( + queryInfo: QueryInfo( + tableName: queryInfo.tableName, + idColumnName: queryInfo.idColumnName, + requiredJoinSQL: queryInfo.requiredJoinSQL, + filterSQL: filterSQL, + groupSQL: queryInfo.groupSQL, + orderSQL: queryInfo.orderSQL + ), + pageSize: pageSize, + totalCount: totalCount, + firstPageOffset: firstPageOffset, + currentIds: currentIds + ) + } } struct LoadResult { @@ -225,6 +242,12 @@ public extension PagedData { /// This will attempt to load a page of data after the last item in the cache case pageAfter + /// This will attempt to load the next `count` items before the first item in the cache + case numberBefore(count: Int) + + /// This will attempt to load the next `count` items after the last item in the cache + case numberAfter(count: Int) + /// This will jump to the specified id, loading a page around it and clearing out any /// data that was previously cached /// @@ -232,9 +255,15 @@ public extension PagedData { /// cached data (plus the padding amount) then it'll load up to that data (plus padding) case jumpTo(id: ID, padding: Int) - /// This will refetched all of the currently fetched data + /// This will refetch all of the currently fetched data case reloadCurrent(insertedIds: Set, deletedIds: Set) + /// This will load the new items added in a specific update + /// + /// **Note:** This `Target` should not be used if existing items can be modified as a result of other items being inserted + /// or deleted + case newItems(insertedIds: Set, deletedIds: Set) + public var reloadCurrent: Target { .reloadCurrent(insertedIds: [], deletedIds: []) } public static func reloadCurrent(insertedIds: Set) -> Target { return .reloadCurrent(insertedIds: insertedIds, deletedIds: []) @@ -267,6 +296,7 @@ public extension PagedData.LoadedInfo { var newLimit: Int var newFirstPageOffset: Int var mergeStrategy: ([ID], [ID]) -> [ID] + var newIdStrategy: ([ID], [ID]) -> [ID] let newTotalCount: Int = PagedData.totalCount( db, tableName: queryInfo.tableName, @@ -280,18 +310,35 @@ public extension PagedData.LoadedInfo { newLimit = pageSize newFirstPageOffset = 0 mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, new in new } // Only newly fetched case .pageBefore: newLimit = min(firstPageOffset, pageSize) newOffset = max(0, firstPageOffset - newLimit) newFirstPageOffset = newOffset mergeStrategy = { old, new in (new + old) } // Prepend new page + newIdStrategy = { _, new in new } // Only newly fetched case .pageAfter: newOffset = firstPageOffset + currentIds.count newLimit = pageSize newFirstPageOffset = firstPageOffset mergeStrategy = { old, new in (old + new) } // Append new page + newIdStrategy = { _, new in new } // Only newly fetched + + case .numberBefore(let count): + newLimit = count + newOffset = max(0, firstPageOffset - newLimit) + newFirstPageOffset = newOffset + mergeStrategy = { old, new in (new + old) } // Prepend new items + newIdStrategy = { _, new in new } // Only newly fetched + + case .numberAfter(let count): + newOffset = firstPageOffset + currentIds.count + newLimit = count + newFirstPageOffset = firstPageOffset + mergeStrategy = { old, new in (old + new) } // Append new items + newIdStrategy = { _, new in new } // Only newly fetched case .initialPageAround(let id): let maybeIndex: Int? = PagedData.index( @@ -312,7 +359,8 @@ public extension PagedData.LoadedInfo { newOffset = max(0, targetIndex - halfPage) newLimit = pageSize newFirstPageOffset = newOffset - mergeStrategy = { _, new in new } // Replace old with new + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, new in new } // Only newly fetched case .jumpTo(let targetId, let padding): /// If it's already loaded then no need to do anything @@ -345,12 +393,14 @@ public extension PagedData.LoadedInfo { newLimit = firstPageOffset - newOffset newFirstPageOffset = newOffset mergeStrategy = { old, new in (new + old) } // Prepend new page + newIdStrategy = { _, new in new } // Only newly fetched } else if isCloseAfter { newOffset = lastIndex + 1 newLimit = (targetIndex - lastIndex) + padding newFirstPageOffset = firstPageOffset mergeStrategy = { old, new in (old + new) } // Append new page + newIdStrategy = { _, new in new } // Only newly fetched } else { /// The target is too far away so we need to do a new fetch @@ -362,7 +412,16 @@ public extension PagedData.LoadedInfo { newOffset = self.firstPageOffset newLimit = max(pageSize, finalSet.count) newFirstPageOffset = self.firstPageOffset - mergeStrategy = { _, new in new } // Replace old with new + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { _, fetchedIds in fetchedIds } // Consider all as new + + case .newItems(let insertedIds, let deletedIds): + let finalSet: Set = Set(currentIds).union(insertedIds).subtracting(deletedIds) + newOffset = self.firstPageOffset + newLimit = max(pageSize, finalSet.count) + newFirstPageOffset = self.firstPageOffset + mergeStrategy = { _, new in new } // Replace old with new + newIdStrategy = { old, new in new.filter { !old.contains($0) } } // Only newly fetched } /// Now that we have the limit and offset actually load the data @@ -386,7 +445,7 @@ public extension PagedData.LoadedInfo { firstPageOffset: newFirstPageOffset, currentIds: mergeStrategy(currentIds, newIds) ), - newIds: newIds + newIds: newIdStrategy(currentIds, newIds) ) } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index fefcc78e19..8e94503516 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -683,6 +683,28 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs ), nil ) + + case .numberBefore(let count): + let updatedOffset: Int = max(0, (currentPageInfo.pageOffset - count)) + + return ( + ( + count, + updatedOffset, + updatedOffset + ), + nil + ) + + case .numberAfter(let count): + return ( + ( + count, + (currentPageInfo.pageOffset + currentPageInfo.currentCount), + currentPageInfo.pageOffset + ), + nil + ) case .untilInclusive(let targetId, let padding): // If we want to focus on a specific item then we need to find it's index in @@ -791,6 +813,17 @@ public class PagedDatabaseObserver: IdentifiableTransactionObs ), nil ) + + case .newItems: + Log.error(.cat, "Used `.newItems` when in PagedDatabaseObserver which is not supported") + return ( + ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + currentPageInfo.pageOffset + ), + nil + ) } }() @@ -941,8 +974,11 @@ private extension PagedData { case initialPageAround(id: SQLExpression) case pageBefore case pageAfter + case numberBefore(Int) + case numberAfter(Int) case jumpTo(id: SQLExpression, paddingForInclusive: Int) case reloadCurrent + case newItems /// This will be used when `jumpTo` is called and the `id` is within a single `pageSize` of the currently /// cached data (plus the padding amount) @@ -960,11 +996,14 @@ private extension PagedData.Target { case .initialPageAround(let id): return .initialPageAround(id: id.sqlExpression) case .pageBefore: return .pageBefore case .pageAfter: return .pageAfter + case .numberBefore(let count): return .numberBefore(count) + case .numberAfter(let count): return .numberAfter(count) case .jumpTo(let id, let padding): return .jumpTo(id: id.sqlExpression, paddingForInclusive: padding) case .reloadCurrent: return .reloadCurrent + case .newItems: return .newItems } } } diff --git a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift index bb0cb7ab73..e01f648422 100644 --- a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift +++ b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift @@ -81,6 +81,10 @@ public enum ObservationContext { // MARK: - Convenience +public extension ObservingDatabase { + var lastInsertedRowID: Int64 { originalDb.lastInsertedRowID } +} + public extension FetchableRecord where Self: TableRecord { static func fetchAll(_ db: ObservingDatabase) throws -> [Self] { return try self.fetchAll(db.originalDb) @@ -111,6 +115,12 @@ public extension FetchableRecord where Self: TableRecord, Self: Identifiable, Se } } +public extension FetchRequest { + func fetchCount(_ db: ObservingDatabase) throws -> Int { + return try self.fetchCount(db.originalDb) + } +} + public extension FetchRequest where Self.RowDecoder: FetchableRecord { func fetchCursor(_ db: ObservingDatabase) throws -> RecordCursor { return try self.fetchCursor(db.originalDb) diff --git a/SessionUtilitiesKit/Observations/DebounceTaskManager.swift b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift index 3a85fa0119..9b10c3ab81 100644 --- a/SessionUtilitiesKit/Observations/DebounceTaskManager.swift +++ b/SessionUtilitiesKit/Observations/DebounceTaskManager.swift @@ -31,6 +31,7 @@ public actor DebounceTaskManager { debounceTask?.cancel() debounceTask = Task { [weak self] in guard let self = self else { return } + guard !Task.isCancelled else { return } do { /// Only debounce if we want to @@ -40,7 +41,12 @@ public actor DebounceTaskManager { guard !Task.isCancelled else { return } let eventsToProcess: [Event] = await self.clearPendingEvents() - await self.action?(eventsToProcess) + + /// Execute the `action` in a detached task so that it avoids inheriting any potential cancelled state from the calling + /// task, since we capture `self` weakly we don't need to worry about it outliving the owning object either + Task.detached { [weak self] in + await self?.action?(eventsToProcess) + } } catch { // Task was cancelled so no need to do anything } @@ -52,9 +58,15 @@ public actor DebounceTaskManager { debounceTask = Task { [weak self] in guard let self = self else { return } + guard !Task.isCancelled else { return } let eventsToProcess: [Event] = await self.clearPendingEvents() - await self.action?(eventsToProcess) + + /// Execute the `action` in a detached task so that it avoids inheriting any potential cancelled state from the calling + /// task, since we capture `self` weakly we don't need to worry about it outliving the owning object either + Task.detached { [weak self] in + await self?.action?(eventsToProcess) + } } } diff --git a/SessionUtilitiesKit/Observations/ObservationBuilder.swift b/SessionUtilitiesKit/Observations/ObservationBuilder.swift index 89b1e3a405..8040e95524 100644 --- a/SessionUtilitiesKit/Observations/ObservationBuilder.swift +++ b/SessionUtilitiesKit/Observations/ObservationBuilder.swift @@ -238,7 +238,7 @@ private actor QueryRunner { oldListenerTask?.cancel() } - /// Only yielf the new result if the value has changed to prevent redundant updates + /// Only yield the new result if the value has changed to prevent redundant updates if isInitialQuery || newResult != self.lastValue { self.lastValue = newResult continuation.yield(newResult) diff --git a/SessionUtilitiesKit/Observations/ObservationManager.swift b/SessionUtilitiesKit/Observations/ObservationManager.swift index e0b8388818..99d4e0cc43 100644 --- a/SessionUtilitiesKit/Observations/ObservationManager.swift +++ b/SessionUtilitiesKit/Observations/ObservationManager.swift @@ -100,14 +100,38 @@ public extension ObservationManager { // MARK: - Convenience public extension Dependencies { + func notify( + priority: ObservationManager.Priority = .standard, + events: [ObservedEvent?] + ) async { + guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return } + + await self[singleton: .observationManager].notify(priority: priority, events: events) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey?, + value: T? + ) async { + guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return } + + await notify(priority: priority, events: [event]) + } + + func notify( + priority: ObservationManager.Priority = .standard, + key: ObservableKey + ) async { + await notify(priority: priority, events: [ObservedEvent(key: key, value: nil)]) + } + @discardableResult func notifyAsync( priority: ObservationManager.Priority = .standard, events: [ObservedEvent?] ) -> Task { - guard let events: [ObservedEvent] = events.compactMap({ $0 }).nullIfEmpty else { return Task {} } - - return Task(priority: priority.taskPriority) { [observationManager = self[singleton: .observationManager]] in - await observationManager.notify(priority: priority, events: events) + return Task(priority: priority.taskPriority) { [weak self] in + await self?.notify(priority: priority, events: events) } } @@ -116,9 +140,7 @@ public extension Dependencies { key: ObservableKey?, value: T? ) -> Task { - guard let event: ObservedEvent = key.map({ ObservedEvent(key: $0, value: value) }) else { return Task {} } - - return notifyAsync(priority: priority, events: [event]) + return notifyAsync(priority: priority, events: [key.map { ObservedEvent(key: $0, value: value) }]) } @discardableResult func notifyAsync( diff --git a/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift b/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift index e69de29bb2..e699ed53ab 100644 --- a/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/ArraySection+Utilities.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit + +public extension ArraySection { + func appending(_ element: Element) -> ArraySection { + return appending(contentsOf: [element]) + } + + func appending(contentsOf elements: [Element]) -> ArraySection { + return ArraySection( + model: model, + elements: self.elements + elements + ) + } +} diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index a79cf88c3f..72a4657363 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -37,7 +37,6 @@ public enum AppSetup { SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) SessionEnvironment.shared = SessionEnvironment( - audioSession: OWSAudioSession(), proximityMonitoringManager: OWSProximityMonitoringManagerImpl(using: dependencies), windowManager: OWSWindowManager(default: ()) ) @@ -115,6 +114,11 @@ public enum AppSetup { unreadCount: userInfo.unreadCount ) + // FIXME: The launch process should be made async/await and this called correctly + Task.detached(priority: .medium) { + await dependencies[singleton: .communityManager].loadCacheIfNeeded() + } + Task.detached(priority: .medium) { dependencies[singleton: .extensionHelper].replicateAllConfigDumpsIfNeeded( userSessionId: userInfo.sessionId, From 402e577f97041dbe8a5563af2dd288cec48b5c30 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 13 Nov 2025 16:46:29 +1100 Subject: [PATCH 16/66] Started adding pro variables to profile info and wiring it through the UI --- Session.xcodeproj/project.pbxproj | 16 +-- .../ConversationVC+Interaction.swift | 39 ++--- .../Conversations/ConversationViewModel.swift | 48 ++++++- .../Message Cells/VisibleMessageCell.swift | 3 +- Session/Home/HomeViewModel.swift | 13 ++ .../MessageInfoScreen.swift | 49 ++----- .../DeveloperSettingsProViewModel.swift | 135 +++++++++--------- SessionMessagingKit/Configuration.swift | 3 +- .../Migrations/_048_SessionProChanges.swift | 26 ++++ .../Database/Models/Contact.swift | 9 -- .../Database/Models/GroupMember.swift | 19 --- .../Database/Models/Interaction.swift | 22 +-- .../Database/Models/Profile.swift | 50 ++++--- .../Jobs/GroupInviteMemberJob.swift | 6 +- .../Config Handling/LibSession+Pro.swift | 4 +- .../LibSession+UserProfile.swift | 16 --- .../LibSession+SessionMessagingKit.swift | 2 - .../Messages/Decoding/DecodedMessage.swift | 2 +- SessionMessagingKit/Messages/Message.swift | 13 +- .../Messages/MessageError.swift | 6 +- .../VisibleMessage+Profile.swift | 60 +++++--- .../Open Groups/CommunityManager.swift | 6 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 23 +-- .../SessionPro/SessionProManager.swift | 8 +- .../SessionPro/Types/SessionProConfig.swift | 20 --- .../SessionProDecodedProForMessage.swift | 2 +- .../Types/SessionProExtraFeatures.swift | 29 ---- .../SessionPro/Types/SessionProFeatures.swift | 26 +++- .../Types/SessionProFeaturesForMessage.swift | 8 +- .../SessionPro/Types/SessionProStatus.swift | 2 +- .../Shared Models/MessageViewModel.swift | 26 +++- .../ObservableKey+SessionMessagingKit.swift | 6 + .../Utilities/Profile+Updating.swift | 76 +++++++++- .../SessionPro/SessionProAPI.swift | 7 +- .../Types/AddProPaymentResponseStatus.swift | 35 +++++ .../SessionPro/Types/ProProof.swift | 10 +- .../SessionPro/Types/UserTransaction.swift | 7 +- SessionTests/Database/DatabaseSpec.swift | 3 +- .../Components/SwiftUI/UserProfileModal.swift | 10 +- SessionUtilitiesKit/General/Feature.swift | 16 +-- SessionUtilitiesKit/Utilities/Version.swift | 2 +- 42 files changed, 496 insertions(+), 369 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProConfig.swift delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a9d234ab47..08fd4e972e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -960,12 +960,13 @@ FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A12EBAA6A500E59F94 /* Envelope.swift */; }; FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; - FD99A3A82EBB0EE500E59F94 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */; }; FD99A3AA2EBBF20100E59F94 /* ArraySection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */; }; FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AB2EBC1B6C00E59F94 /* Server.swift */; }; FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */; }; FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */; }; FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */; }; + FD99A3B82EC5882A00E59F94 /* AddProPaymentResponseStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */; }; + FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -983,7 +984,6 @@ FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */; }; FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */; }; FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProFeatures.swift */; }; - FDAA36CC2EB47D7D0040603E /* SessionProExtraFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */; }; FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */; }; FDAA36D02EB485F20040603E /* SessionProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; @@ -2271,12 +2271,13 @@ FD99A3A12EBAA6A500E59F94 /* Envelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Envelope.swift; sourceTree = ""; }; FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; - FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArraySection+Utilities.swift"; sourceTree = ""; }; FD99A3AB2EBC1B6C00E59F94 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchablePair.swift; sourceTree = ""; }; FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSAudioPlayer.swift; sourceTree = ""; }; FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _047_DropUnneededColumnsAndTables.swift; sourceTree = ""; }; + FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentResponseStatus.swift; sourceTree = ""; }; + FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _048_SessionProChanges.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -2291,7 +2292,6 @@ FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeaturesForMessage.swift; sourceTree = ""; }; FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatureStatus.swift; sourceTree = ""; }; FDAA36C92EB476060040603E /* SessionProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatures.swift; sourceTree = ""; }; - FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExtraFeatures.swift; sourceTree = ""; }; FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedProForMessage.swift; sourceTree = ""; }; FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProStatus.swift; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; @@ -4144,6 +4144,7 @@ FD0F85642EA82FC2004E0B98 /* Types */ = { isa = PBXGroup; children = ( + FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */, FD306BD12EB031AB00ADB003 /* PaymentItem.swift */, FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */, @@ -4222,6 +4223,7 @@ 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */, FD99A3B52EC562CB00E59F94 /* _047_DropUnneededColumnsAndTables.swift */, + FD99A3B92EC58DD500E59F94 /* _048_SessionProChanges.swift */, ); path = Migrations; sourceTree = ""; @@ -5089,9 +5091,7 @@ FDAA36C42EB474B50040603E /* Types */ = { isa = PBXGroup; children = ( - FD99A3A72EBB0EE200E59F94 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, - FDAA36CB2EB47D7B0040603E /* SessionProExtraFeatures.swift */, FDAA36C92EB476060040603E /* SessionProFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, @@ -6626,6 +6626,7 @@ FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, + FD99A3B82EC5882A00E59F94 /* AddProPaymentResponseStatus.swift in Sources */, FD6B92EA2E77C5D1004463B5 /* NotificationMetadata.swift in Sources */, FD6B92EB2E77C5D1004463B5 /* AuthenticatedRequest.swift in Sources */, FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */, @@ -6887,7 +6888,6 @@ FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, FDE33BBE2D5C3AF100E56F42 /* _037_GroupsExpiredFlag.swift in Sources */, FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */, - FDAA36CC2EB47D7D0040603E /* SessionProExtraFeatures.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, FD2272FD2C352D8E004D8A6C /* LibSession+ConvoInfoVolatile.swift in Sources */, FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, @@ -6919,7 +6919,6 @@ FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, - FD99A3A82EBB0EE500E59F94 /* SessionProConfig.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, FD2272772C32911C004D8A6C /* AttachmentUploadJob.swift in Sources */, FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */, @@ -6998,6 +6997,7 @@ FD99A3B62EC562DB00E59F94 /* _047_DropUnneededColumnsAndTables.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, + FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, FD245C55285065E500B966DD /* CommunityManager.swift in Sources */, FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 062efe0885..eae14bc705 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -776,22 +776,29 @@ extension ConversationVC: Task.detached(priority: .userInitiated) { [weak self] in guard let self = self else { return } - let optimisticData: ConversationViewModel.OptimisticMessageData = await viewModel.optimisticallyAppendOutgoingMessage( - text: processedText, - sentTimestampMs: sentTimestampMs, - attachments: attachments, - linkPreviewDraft: linkPreviewDraft, - quoteModel: quoteModel - ) - await approveMessageRequestIfNeeded( - for: self.viewModel.state.threadId, - threadVariant: self.viewModel.state.threadVariant, - displayName: self.viewModel.state.threadViewModel.displayName, - isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting - ) - - await sendMessage(optimisticData: optimisticData) + do { + let optimisticData: ConversationViewModel.OptimisticMessageData = try await viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel + ) + await approveMessageRequestIfNeeded( + for: self.viewModel.state.threadId, + threadVariant: self.viewModel.state.threadVariant, + displayName: self.viewModel.state.threadViewModel.displayName, + isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting + ) + + await sendMessage(optimisticData: optimisticData) + } + catch { + await MainActor.run { [weak self] in + self?.handleCharacterLimitLabelTapped() + } + } } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 39b7fd8de2..f4ed2da506 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -648,6 +648,19 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .name(let name): profileData = profileData.with(name: name) case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHash): + let finalFeatures: SessionPro.Features = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return features + .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) + }() + + profileData = profileData.with( + proFeatures: .set(to: finalFeatures), + proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), + proGenIndexHash: .set(to: proGenIndexHash) + ) } profileCache[eventValue.id] = profileData @@ -898,7 +911,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// Update the caches with the newly fetched values quoteMap.merge(fetchedQuoteMap, uniquingKeysWith: { _, new in new }) - fetchedProfiles.forEach { profileCache[$0.id] = $0 } + fetchedProfiles.forEach { profile in + let finalFeatures: SessionPro.Features = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return profile.proFeatures + .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) + }() + + profileCache[profile.id] = profile + } fetchedLinkPreviews.forEach { linkPreviewCache[$0.url, default: []].append($0) } fetchedAttachments.forEach { attachmentCache[$0.id] = $0 } fetchedReactions.forEach { interactionId, reactions in @@ -1234,10 +1256,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold attachments: [PendingAttachment]?, linkPreviewDraft: LinkPreviewDraft?, quoteModel: QuotedReplyModel? - ) async -> OptimisticMessageData { + ) async throws -> OptimisticMessageData { // Generate the optimistic data let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages let currentState: State = await self.state + let proFeatures: SessionPro.Features = try { + let userProfileFeatures: SessionPro.Features = .none // TODO: [PRO] Need to add in `proBadge` if enabled + let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features( + for: (text ?? ""), + features: userProfileFeatures + ) + + switch result.status { + case .success: return result.features + case .utfDecodingError: + Log.warn(.messageSender, "Failed to extract features for message, falling back to manual handling") + guard (text ?? "").utf16.count > SessionPro.CharacterLimit else { + return userProfileFeatures + } + + return userProfileFeatures.union(.largerCharacterLimit) + + case .exceedsCharacterLimit: throw MessageError.messageTooLarge + } + }() let interaction: Interaction = Interaction( threadId: currentState.threadId, threadVariant: currentState.threadVariant, @@ -1253,7 +1295,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), expiresInSeconds: currentState.threadViewModel.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewDraft?.urlString, - isProMessage: (text.defaulting(to: "").utf16.count > SessionPro.CharacterLimit),//dependencies[cache: .libSession].isSessionPro, // TODO: [PRO] Ditch this? + proFeatures: proFeatures, using: dependencies ) var optimisticAttachments: [Attachment]? diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 22ae514454..645559451c 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -352,8 +352,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let profileShouldBeVisible: Bool = ( isGroupThread && cellViewModel.canHaveProfile && - cellViewModel.shouldShowDisplayPicture && - cellViewModel.profile != nil + cellViewModel.shouldShowDisplayPicture ) profilePictureView.isHidden = !cellViewModel.canHaveProfile profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 64b8056203..63181a4469 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -297,6 +297,19 @@ public class HomeViewModel: NavigatableStateHolder { case .name(let name): userProfile = userProfile.with(name: name) case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHash): + let finalFeatures: SessionPro.Features = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return features + .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) + }() + + userProfile = userProfile.with( + proFeatures: .set(to: finalFeatures), + proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), + proGenIndexHash: .set(to: proGenIndexHash) + ) } // TODO: [Database Relocation] All profiles should be stored in the `profileCache` diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index e1caec5328..8225fd8b52 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -17,8 +17,14 @@ struct MessageInfoScreen: View { let isMessageFailed: Bool let isCurrentUser: Bool let profileInfo: ProfilePictureView.Info? + + /// These are the features that were enabled at the time the message was received let proFeatures: [ProFeature] + /// This flag is separate to the `proFeatures` because it should be based on the _current_ pro state of the user rather than + /// the state the user was in when the message was sent + let shouldShowProBadge: Bool + func ctaVariant(currentUserIsPro: Bool) -> ProCTAModal.Variant { guard let firstFeature: ProFeature = proFeatures.first, proFeatures.count > 1 else { return .generic @@ -102,7 +108,8 @@ struct MessageInfoScreen: View { profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), using: dependencies ).info, - proFeatures: ProFeature.from(messageViewModel.proFeatures) + proFeatures: ProFeature.from(messageViewModel.proFeatures), + shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge) ) } @@ -557,39 +564,6 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } - // TODO: [PRO] Need to add the mocking back -// private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { -// var proFeatures: [String] = [] -// var proCTAVariant: ProCTAModal.Variant = .generic -// -// guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } -// -// // TODO: [PRO] Add this back -//// if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { -// proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) -//// } -// -// // TODO: [PRO] Count this properly -// if ( -// messageViewModel.isProMessage && -// messageViewModel.body.defaulting(to: "").utf16.count > SessionPro.CharacterLimit || -// dependencies[feature: .messageFeatureLongMessage] -// ) { -// proFeatures.append("proIncreasedMessageLengthFeature".localized()) -// proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) -// } -// -// if ( -// ImageDataManager.isAnimatedImage(profileInfo?.source) || -// dependencies[feature: .messageFeatureAnimatedAvatar] -// ) { -// proFeatures.append("proAnimatedDisplayPictureFeature".localized()) -// proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) -// } -// -// return (proFeatures, proCTAVariant) -// } - private func showSessionProCTAIfNeeded() { guard viewModel.dependencies[feature: .sessionProEnabled] && @@ -665,15 +639,14 @@ struct MessageInfoScreen: View { let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( - info: .init( + info: UserProfileModal.Info( sessionId: sessionId, blindedId: blindedId, qrCodeImage: qrCodeImage, profileInfo: profileInfo, displayName: displayName, contactDisplayName: contactDisplayName, - // TODO: [PRO] Pretty sure this should be based on their current profile pro features (ie. if they don't have any pro features enabled then we shouldn't show their pro status) - isProUser: false,//dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), + shouldShowProBadge: viewModel.messageViewModel.profile.proFeatures.contains(.proBadge), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: viewModel.onStartThread, onProBadgeTapped: self.showSessionProCTAIfNeeded @@ -929,7 +902,7 @@ struct MessageInfoView_Previews: PreviewProvider { body: "Mauris sapien dui, sagittis et fringilla eget, tincidunt vel mauris. Mauris bibendum quis ipsum ac pulvinar. Integer semper elit vitae placerat efficitur. Quisque blandit scelerisque orci, a fringilla dui. In a sollicitudin tortor. Vivamus consequat sollicitudin felis, nec pretium dolor bibendum sit amet. Integer non congue risus, id imperdiet diam. Proin elementum enim at felis commodo semper. Pellentesque magna magna, laoreet nec hendrerit in, suscipit sit amet risus. Nulla et imperdiet massa. Donec commodo felis quis arcu dignissim lobortis. Praesent nec fringilla felis, ut pharetra sapien. Donec ac dignissim nisi, non lobortis justo. Nulla congue velit nec sodales bibendum. Nullam feugiat, mauris ac consequat posuere, eros sem dignissim nulla, ac convallis dolor sem rhoncus dolor. Cras ut luctus risus, quis viverra mauris.", timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), state: .failed, - isProMessage: true, + proFeatures: .proBadge, using: dependencies ), reactionInfo: nil, diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 5acd7abac5..e318796838 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -71,11 +71,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case enableSessionPro case proStatus - case allUsersSessionPro + case proBadgeEverywhere - case messageFeatureProBadge - case messageFeatureLongMessage - case messageFeatureAnimatedAvatar + case forceMessageFeatureProBadge + case forceMessageFeatureLongMessage + case forceMessageFeatureAnimatedAvatar case purchaseProSubscription case manageProSubscriptions @@ -93,11 +93,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .enableSessionPro: return "enableSessionPro" case .proStatus: return "proStatus" - case .allUsersSessionPro: return "allUsersSessionPro" + case .proBadgeEverywhere: return "proBadgeEverywhere" - case .messageFeatureProBadge: return "messageFeatureProBadge" - case .messageFeatureLongMessage: return "messageFeatureLongMessage" - case .messageFeatureAnimatedAvatar: return "messageFeatureAnimatedAvatar" + case .forceMessageFeatureProBadge: return "forceMessageFeatureProBadge" + case .forceMessageFeatureLongMessage: return "forceMessageFeatureLongMessage" + case .forceMessageFeatureAnimatedAvatar: return "forceMessageFeatureAnimatedAvatar" case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" @@ -118,11 +118,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .enableSessionPro: result.append(.enableSessionPro); fallthrough case .proStatus: result.append(.proStatus); fallthrough - case .allUsersSessionPro: result.append(.allUsersSessionPro); fallthrough + case .proBadgeEverywhere: result.append(.proBadgeEverywhere); fallthrough - case .messageFeatureProBadge: result.append(.messageFeatureProBadge); fallthrough - case .messageFeatureLongMessage: result.append(.messageFeatureLongMessage); fallthrough - case .messageFeatureAnimatedAvatar: result.append(.messageFeatureAnimatedAvatar) + case .forceMessageFeatureProBadge: result.append(.forceMessageFeatureProBadge); fallthrough + case .forceMessageFeatureLongMessage: result.append(.forceMessageFeatureLongMessage); fallthrough + case .forceMessageFeatureAnimatedAvatar: result.append(.forceMessageFeatureAnimatedAvatar) case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough @@ -149,11 +149,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let sessionProEnabled: Bool let mockCurrentUserSessionProBackendStatus: Network.SessionPro.BackendUserProStatus? - let allUsersSessionPro: Bool + let proBadgeEverywhere: Bool - let messageFeatureProBadge: Bool - let messageFeatureLongMessage: Bool - let messageFeatureAnimatedAvatar: Bool + let forceMessageFeatureProBadge: Bool + let forceMessageFeatureLongMessage: Bool + let forceMessageFeatureAnimatedAvatar: Bool let products: [Product] let purchasedProduct: Product? @@ -181,10 +181,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public let observedKeys: Set = [ .feature(.sessionProEnabled), .feature(.mockCurrentUserSessionProBackendStatus), - .feature(.allUsersSessionPro), - .feature(.messageFeatureProBadge), - .feature(.messageFeatureLongMessage), - .feature(.messageFeatureAnimatedAvatar), + .feature(.proBadgeEverywhere), + .feature(.forceMessageFeatureProBadge), + .feature(.forceMessageFeatureLongMessage), + .feature(.forceMessageFeatureAnimatedAvatar), .updateScreen(DeveloperSettingsProViewModel.self) ] @@ -193,11 +193,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], - allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], - messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], - messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], - messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar], + forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], + forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], + forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], products: [], purchasedProduct: nil, @@ -268,10 +268,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], - allUsersSessionPro: dependencies[feature: .allUsersSessionPro], - messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], - messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], - messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar], + proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], + forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], + forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], + forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], products: products, purchasedProduct: purchasedProduct, purchaseError: purchaseError, @@ -341,72 +341,71 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } ), SessionCell.Info( - id: .allUsersSessionPro, - title: "Everyone is a Pro", + id: .proBadgeEverywhere, + title: "Show the Pro Badge everywhere", subtitle: """ - Treat all incoming messages as Pro messages. - Treat all contacts, groups as Session Pro. + Force the pro badge to show everywhere. + + Note: On the "Message Info" screen this will make the Pro Badge appear against the sender profile info, but the message feature pro badge will show based on the "Message Feature: Pro Badge" setting below. """, trailingAccessory: .toggle( - state.allUsersSessionPro, - oldValue: previousState.allUsersSessionPro + state.proBadgeEverywhere, + oldValue: previousState.proBadgeEverywhere ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .allUsersSessionPro, - to: !state.allUsersSessionPro + feature: .proBadgeEverywhere, + to: !state.proBadgeEverywhere ) } - ) - ] - ) - - if state.allUsersSessionPro { - features = features.appending(contentsOf: [ + ), SessionCell.Info( - id: .messageFeatureProBadge, - title: SessionCell.TextInfo("Message Feature: Pro Badge", font: .subtitle), + id: .forceMessageFeatureProBadge, + title: "Message Feature: Pro Badge", + subtitle: "Force all messages to show the \"Pro Badge\" feature.", trailingAccessory: .toggle( - state.messageFeatureProBadge, - oldValue: previousState.messageFeatureProBadge + state.forceMessageFeatureProBadge, + oldValue: previousState.forceMessageFeatureProBadge ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .messageFeatureProBadge, - to: !state.messageFeatureProBadge + feature: .forceMessageFeatureProBadge, + to: !state.forceMessageFeatureProBadge ) } ), SessionCell.Info( - id: .messageFeatureLongMessage, - title: SessionCell.TextInfo("Message Feature: Long Message", font: .subtitle), + id: .forceMessageFeatureLongMessage, + title: "Message Feature: Long Message", + subtitle: "Force all messages to show the \"Long Message\" feature.", trailingAccessory: .toggle( - state.messageFeatureLongMessage, - oldValue: previousState.messageFeatureLongMessage + state.forceMessageFeatureLongMessage, + oldValue: previousState.forceMessageFeatureLongMessage ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .messageFeatureLongMessage, - to: !state.messageFeatureLongMessage + feature: .forceMessageFeatureLongMessage, + to: !state.forceMessageFeatureLongMessage ) } ), SessionCell.Info( - id: .messageFeatureAnimatedAvatar, - title: SessionCell.TextInfo("Message Feature: Animated Avatar", font: .subtitle), + id: .forceMessageFeatureAnimatedAvatar, + title: "Message Feature: Animated Avatar", + subtitle: "Force all messages to show the \"Animated Avatar\" feature.", trailingAccessory: .toggle( - state.messageFeatureAnimatedAvatar, - oldValue: previousState.messageFeatureAnimatedAvatar + state.forceMessageFeatureAnimatedAvatar, + oldValue: previousState.forceMessageFeatureAnimatedAvatar ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .messageFeatureAnimatedAvatar, - to: !state.messageFeatureAnimatedAvatar + feature: .forceMessageFeatureAnimatedAvatar, + to: !state.forceMessageFeatureAnimatedAvatar ) } ) - ]) - } + ] + ) // MARK: - Actual Pro Transactions and APIs @@ -549,10 +548,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public static func disableDeveloperMode(using dependencies: Dependencies) { let features: [FeatureConfig] = [ .sessionProEnabled, - .allUsersSessionPro, - .messageFeatureProBadge, - .messageFeatureLongMessage, - .messageFeatureAnimatedAvatar, + .proBadgeEverywhere, + .forceMessageFeatureProBadge, + .forceMessageFeatureLongMessage, + .forceMessageFeatureAnimatedAvatar, ] features.forEach { feature in @@ -575,8 +574,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: nil) } - if dependencies.hasSet(feature: .allUsersSessionPro) { - dependencies.set(feature: .allUsersSessionPro, to: nil) + if dependencies.hasSet(feature: .proBadgeEverywhere) { + dependencies.set(feature: .proBadgeEverywhere, to: nil) } } diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 9849d0f243..fa9ff36804 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -50,7 +50,8 @@ public enum SNMessagingKit { _044_AddProMessageFlag.self, _045_LastProfileUpdateTimestamp.self, _046_RemoveQuoteUnusedColumnsAndForeignKeys.self, - _047_DropUnneededColumnsAndTables.self + _047_DropUnneededColumnsAndTables.self, + _048_SessionProChanges.self ] public static func configure(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift new file mode 100644 index 0000000000..1442d3696d --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _048_SessionProChanges: Migration { + static let identifier: String = "SessionProChanges" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + try db.alter(table: "interaction") { t in + t.drop(column: "isProMessage") + t.add(column: "proFeatures", .integer).defaults(to: 0) + } + + try db.alter(table: "profile") { t in + t.add(column: "proFeatures", .integer).defaults(to: 0) + t.add(column: "proExpiryUnixTimestampMs", .integer).defaults(to: 0) + t.add(column: "proGenIndexHash", .text) + } + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 86ca6e0d2e..eba4035727 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -12,9 +12,6 @@ public struct Contact: Codable, Sendable, PagableRecord, Identifiable, Equatable public static var databaseTableName: String { "contact" } public static let idColumn: ColumnExpression = Columns.id - internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id]) - public static let profile = hasOne(Profile.self, using: Profile.contactForeignKey) - public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id @@ -48,12 +45,6 @@ public struct Contact: Codable, Sendable, PagableRecord, Identifiable, Equatable /// This flag is used to determine whether this contact has ever been blocked (will be included in the config message if so) public let hasBeenBlocked: Bool - // MARK: - Relationships - - public var profile: QueryInterfaceRequest { - request(for: Contact.profile) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 37e78424be..23f6bccd2c 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -7,11 +7,6 @@ import SessionUtilitiesKit public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } - internal static let openGroupForeignKey = ForeignKey([Columns.groupId], to: [OpenGroup.Columns.threadId]) - internal static let closedGroupForeignKey = ForeignKey([Columns.groupId], to: [ClosedGroup.Columns.threadId]) - public static let openGroup = belongsTo(OpenGroup.self, using: openGroupForeignKey) - public static let closedGroup = belongsTo(ClosedGroup.self, using: closedGroupForeignKey) - public static let profile = hasOne(Profile.self, using: Profile.groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -48,20 +43,6 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis public let roleStatus: RoleStatus public let isHidden: Bool - // MARK: - Relationships - - public var openGroup: QueryInterfaceRequest { - request(for: GroupMember.openGroup) - } - - public var closedGroup: QueryInterfaceRequest { - request(for: GroupMember.closedGroup) - } - - public var profile: QueryInterfaceRequest { - request(for: GroupMember.profile) - } - // MARK: - Initialization public init( diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 8a0f5cc5d6..9ebef5f225 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -52,7 +52,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, case mostRecentFailureText // Session Pro - case isProMessage + case proFeatures } public enum Variant: Int, Sendable, Codable, Hashable, DatabaseValueConvertible, CaseIterable { @@ -203,8 +203,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, /// The reason why the most recent attempt to send this message failed public private(set) var mostRecentFailureText: String? - /// A flag indicating if the message sender is a Session Pro user when the message is sent - public let isProMessage: Bool + /// A bitset indicating which Session Pro features were used when this message was sent + public let proFeatures: SessionPro.Features // MARK: - Initialization @@ -230,7 +230,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, state: State, recipientReadTimestampMs: Int64?, mostRecentFailureText: String?, - isProMessage: Bool + proFeatures: SessionPro.Features ) { self.id = id self.serverHash = serverHash @@ -253,7 +253,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, self.state = (variant.isLocalOnly ? .localOnly : state) self.recipientReadTimestampMs = recipientReadTimestampMs self.mostRecentFailureText = mostRecentFailureText - self.isProMessage = isProMessage + self.proFeatures = proFeatures } public init( @@ -275,7 +275,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil, state: Interaction.State? = nil, - isProMessage: Bool = false, + proFeatures: SessionPro.Features = .none, using dependencies: Dependencies ) { self.serverHash = serverHash @@ -312,7 +312,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, self.recipientReadTimestampMs = nil self.mostRecentFailureText = nil - self.isProMessage = isProMessage + self.proFeatures = proFeatures } // MARK: - Custom Database Interaction @@ -402,7 +402,7 @@ public extension Interaction { state: try container.decode(State.self, forKey: .state), recipientReadTimestampMs: try? container.decode(Int64?.self, forKey: .recipientReadTimestampMs), mostRecentFailureText: try? container.decode(String?.self, forKey: .mostRecentFailureText), - isProMessage: (try? container.decode(Bool.self, forKey: .isProMessage)).defaulting(to: false) + proFeatures: try container.decode(SessionPro.Features.self, forKey: .proFeatures) ) } } @@ -446,7 +446,7 @@ public extension Interaction { state: (state ?? self.state), recipientReadTimestampMs: (recipientReadTimestampMs ?? self.recipientReadTimestampMs), mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), - isProMessage: self.isProMessage + proFeatures: self.proFeatures ) } @@ -516,7 +516,7 @@ public extension Interaction { JOIN \(SessionThread.self) ON ( \(thread[.id]) = \(interaction[.threadId]) AND -- Ignore message request threads (these should be counted by the PN extension but - -- seeing the "Message Requests" banner is considered marking the "Unread Message + -- seeing the 'Message Requests' banner is considered marking the "Unread Message -- Request" notification as read) \(thread[.id]) NOT IN \(messageRequestThreadIds) AND ( -- Ignore muted threads @@ -807,7 +807,7 @@ public extension Interaction { ) } .appending(Interaction.notificationIdentifier( - for: "0", + for: "0", // stringlint:ignore threadId: threadId, shouldGroupMessagesForThread: true )) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 6faf2e0234..5fdabc81fd 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -10,11 +10,6 @@ import SessionUtilitiesKit /// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, Differentiable { public static var databaseTableName: String { "profile" } - internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) - internal static let contactForeignKey = ForeignKey([Columns.id], to: [Contact.Columns.id]) - internal static let groupMemberForeignKey = ForeignKey([GroupMember.Columns.profileId], to: [Columns.id]) - public static let contact = hasOne(Contact.self, using: contactForeignKey) - public static let groupMembers = hasMany(GroupMember.self, using: groupMemberForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -29,6 +24,10 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet case profileLastUpdated case blocksCommunityMessageRequests + + case proFeatures + case proExpiryUnixTimestampMs + case proGenIndexHash } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -54,10 +53,14 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// A flag indicating whether this profile has reported that it blocks community message requests public let blocksCommunityMessageRequests: Bool? - /// The Pro Proof for when this profile is updated - // TODO: Implement these when the structure of Session Pro Proof is determined - public let sessionProProof: String? - public var showProBadge: Bool? + /// The Session Pro features enabled for this profile + public let proFeatures: SessionPro.Features + + /// The unix timestamp (in milliseconds) when Session Pro expires for this profile + public let proExpiryUnixTimestampMs: UInt64 + + /// The timestamp when Session Pro expires for this profile + public let proGenIndexHash: String? // MARK: - Initialization @@ -69,8 +72,9 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet displayPictureEncryptionKey: Data? = nil, profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil, - showProBadge: Bool? = nil + proFeatures: SessionPro.Features = .none, + proExpiryUnixTimestampMs: UInt64 = 0, + proGenIndexHash: String? = nil ) { self.id = id self.name = name @@ -79,8 +83,9 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet self.displayPictureEncryptionKey = displayPictureEncryptionKey self.profileLastUpdated = profileLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.sessionProProof = sessionProProof - self.showProBadge = showProBadge + self.proFeatures = proFeatures + self.proExpiryUnixTimestampMs = proExpiryUnixTimestampMs + self.proGenIndexHash = proGenIndexHash } } @@ -106,7 +111,10 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { displayPictureUrl: \(displayPictureUrl.map { "\"\($0)\"" } ?? "null"), displayPictureEncryptionKey: \(displayPictureEncryptionKey?.toHexString() ?? "null"), profileLastUpdated: \(profileLastUpdated.map { "\($0)" } ?? "null"), - blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null") + blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), + proFeatures: \(proFeatures), + proExpiryUnixTimestampMs: \(proExpiryUnixTimestampMs), + proGenIndexHash: \(proGenIndexHash.map { "\($0)" } ?? "null") ) """ } @@ -223,8 +231,9 @@ public extension Profile { displayPictureEncryptionKey: nil, profileLastUpdated: nil, blocksCommunityMessageRequests: nil, - sessionProProof: nil, - showProBadge: nil + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHash: nil ) } @@ -435,7 +444,10 @@ public extension Profile { displayPictureUrl: Update = .useExisting, displayPictureEncryptionKey: Update = .useExisting, profileLastUpdated: Update = .useExisting, - blocksCommunityMessageRequests: Update = .useExisting + blocksCommunityMessageRequests: Update = .useExisting, + proFeatures: Update = .useExisting, + proExpiryUnixTimestampMs: Update = .useExisting, + proGenIndexHash: Update = .useExisting ) -> Profile { return Profile( id: id, @@ -445,7 +457,9 @@ public extension Profile { displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), profileLastUpdated: profileLastUpdated.or(self.profileLastUpdated), blocksCommunityMessageRequests: blocksCommunityMessageRequests.or(self.blocksCommunityMessageRequests), - sessionProProof: self.sessionProProof + proFeatures: proFeatures.or(self.proFeatures), + proExpiryUnixTimestampMs: proExpiryUnixTimestampMs.or(self.proExpiryUnixTimestampMs), + proGenIndexHash: proGenIndexHash.or(self.proGenIndexHash) ) } } diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index 12f24b063d..32dda769c6 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -73,11 +73,7 @@ public enum GroupInviteMemberJob: JobExecutor { groupSessionId: SessionId(.group, hex: threadId), groupName: groupName, memberAuthData: details.memberAuthData, - profile: VisibleMessage.VMProfile( - displayName: adminProfile.name, - profileKey: adminProfile.displayPictureEncryptionKey, - profilePictureUrl: adminProfile.displayPictureUrl - ), + profile: VisibleMessage.VMProfile(profile: adminProfile), sentTimestampMs: UInt64(sentTimestampMs), authMethod: groupAuthMethod, using: dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index b1620bfb97..a4c8cce9c8 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -12,11 +12,11 @@ public extension LibSessionCacheType { func validateProProof(for message: Message?) -> Bool { guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .allUsersSessionPro] + return dependencies[feature: .proBadgeEverywhere] } func validateProProof(for profile: Profile?) -> Bool { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .allUsersSessionPro] + return dependencies[feature: .proBadgeEverywhere] } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index bfab7c967a..a2d2c6d06e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -142,11 +142,6 @@ internal extension LibSessionCacheType { db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } - - // Update the SessionProManager with these changes - db.afterCommit { [sessionProManager = dependencies[singleton: .sessionProManager]] in - Task { await sessionProManager.updateWithLatestFromUserConfig() } - } } } @@ -218,17 +213,6 @@ public extension LibSession.Cache { return String(cString: profileNamePtr) } - var proConfig: SessionPro.ProConfig? { - var cProConfig: pro_pro_config = pro_pro_config() - - guard - case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), - user_profile_get_pro_config(conf, &cProConfig) - else { return nil } - - return SessionPro.ProConfig(cProConfig) - } - func updateProfile( displayName: Update, displayPictureUrl: Update, diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 66cb2cff5f..4dd9e6be6b 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1075,7 +1075,6 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func set(_ key: Setting.EnumKey, _ value: T?) var displayName: String? { get } - var proConfig: SessionPro.ProConfig? { get } func updateProfile( displayName: Update, @@ -1356,7 +1355,6 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { // MARK: - State Access var displayName: String? { return nil } - var proConfig: SessionPro.ProConfig? { return nil } func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} diff --git a/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift index d29394ac7e..203c950e4c 100644 --- a/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift +++ b/SessionMessagingKit/Messages/Decoding/DecodedMessage.swift @@ -66,7 +66,7 @@ public struct DecodedMessage: Codable, Equatable { let senderSessionId: SessionId = try SessionId(from: sender) self = DecodedMessage( - content: content.prefix(decodedValue.content_plaintext_unpadded_size), + content: content, sender: senderSessionId, decodedEnvelope: { guard decodedValue.has_envelope else { return nil } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 119554851a..e51a8eec20 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -21,7 +21,7 @@ public class Message: Codable { case expiresInSeconds case expiresStartedAtMs - case proProof + case proFeatures } public var id: String? @@ -45,7 +45,7 @@ public class Message: Codable { public var expiresInSeconds: TimeInterval? public var expiresStartedAtMs: Double? - public var proProof: String? + public var proFeatures: UInt64? // MARK: - Validation @@ -107,7 +107,7 @@ public class Message: Codable { serverHash: String? = nil, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, - proProof: String? = nil + proFeatures: UInt64? = nil ) { self.id = id self.sentTimestampMs = sentTimestampMs @@ -120,7 +120,7 @@ public class Message: Codable { self.serverHash = serverHash self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs - self.proProof = proProof + self.proFeatures = proFeatures } // MARK: - Proto Conversion @@ -658,9 +658,4 @@ public extension Message { self.expiresStartedAtMs = expiresStartedAtMs return self } - - func with(proProof: String?) -> Self { - self.proProof = proProof - return self - } } diff --git a/SessionMessagingKit/Messages/MessageError.swift b/SessionMessagingKit/Messages/MessageError.swift index c42c4d30f5..583a6506a6 100644 --- a/SessionMessagingKit/Messages/MessageError.swift +++ b/SessionMessagingKit/Messages/MessageError.swift @@ -28,6 +28,7 @@ public enum MessageError: Error, CustomStringConvertible { case communitiesDoNotSupportControlMessages case requiresGroupId(String) case requiresGroupIdentityPrivateKey + case messageTooLarge case selfSend case invalidSender @@ -113,8 +114,9 @@ public enum MessageError: Error, CustomStringConvertible { case .invalidRevokedRetrievalMessageHandling: return "Invalid handling of a revoked retrieval message." case .invalidGroupUpdate(let reason): return "Invalid group update (\(reason))." case .communitiesDoNotSupportControlMessages: return "Communities do not support control messages." - case .requiresGroupId(let id): return "Required group id but was given: \(id)" - case .requiresGroupIdentityPrivateKey: return "Requires group identity private key" + case .requiresGroupId(let id): return "Required group id but was given: \(id)." + case .requiresGroupIdentityPrivateKey: return "Requires group identity private key." + case .messageTooLarge: return "Message is too large." case .selfSend: return "Message addressed at self." case .invalidSender: return "Invalid sender." diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index bd0c341f74..d28a3937cf 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -12,17 +12,17 @@ public extension VisibleMessage { public let profilePictureUrl: String? public let updateTimestampSeconds: TimeInterval? public let blocksCommunityMessageRequests: Bool? - public let sessionProProof: String? + public let proFeatures: SessionPro.Features? // MARK: - Initialization - internal init( + private init( displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil, updateTimestampSeconds: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil + proFeatures: SessionPro.Features? = nil ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) @@ -31,12 +31,23 @@ public extension VisibleMessage { self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) self.updateTimestampSeconds = updateTimestampSeconds self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.sessionProProof = sessionProProof + self.proFeatures = proFeatures + } + + internal init(profile: Profile, blocksCommunityMessageRequests: Bool? = nil) { + self.init( + displayName: profile.name, + profileKey: profile.displayPictureEncryptionKey, + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests, + proFeatures: profile.proFeatures + ) } // MARK: - Proto Conversion - public static func fromProto(_ proto: SNProtoDataMessage) -> VMProfile? { + public static func fromProto(_ proto: ProtoWithProfile) -> VMProfile? { guard let profileProto = proto.profile, let displayName = profileProto.displayName @@ -47,8 +58,11 @@ public extension VisibleMessage { profileKey: proto.profileKey, profilePictureUrl: profileProto.profilePicture, updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), - blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), - sessionProProof: nil // TODO: Add Session Pro Proof to profile proto + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? + proto.blocksCommunityMessageRequests : + nil + ), + proFeatures: nil // TODO: [PRO] Add these once the protobuf is updated ) } @@ -73,6 +87,7 @@ public extension VisibleMessage { } dataMessageProto.setProfile(try profileProto.build()) + // TODO: [PRO] Add the 'proFeatures' value once the protobuf is updated return dataMessageProto } @@ -90,21 +105,6 @@ public extension VisibleMessage { // MARK: - MessageRequestResponse - public static func fromProto(_ proto: SNProtoMessageRequestResponse) -> VMProfile? { - guard - let profileProto = proto.profile, - let displayName = profileProto.displayName - else { return nil } - - return VMProfile( - displayName: displayName, - profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture, - updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), - sessionProProof: nil // TODO: Add Session Pro Proof to profile proto - ) - } - public func toProto(isApproved: Bool) -> SNProtoMessageRequestResponse? { guard let displayName = displayName else { Log.warn(.messageSender, "Couldn't construct profile proto from: \(self).") @@ -158,3 +158,19 @@ extension MessageRequestResponse: MessageWithProfile {} extension GroupUpdateInviteMessage: MessageWithProfile {} extension GroupUpdatePromoteMessage: MessageWithProfile {} extension GroupUpdateInviteResponseMessage: MessageWithProfile {} + +// MARK: - ProtoWithProfile + +public protocol ProtoWithProfile { + var profileKey: Data? { get } + var profile: SNProtoLokiProfile? { get } + + var hasBlocksCommunityMessageRequests: Bool { get } + var blocksCommunityMessageRequests: Bool { get } +} + +extension SNProtoDataMessage: ProtoWithProfile {} +extension SNProtoMessageRequestResponse: ProtoWithProfile { + public var hasBlocksCommunityMessageRequests: Bool { return false } + public var blocksCommunityMessageRequests: Bool { return false } +} diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index c4acfa7892..f2f8f77892 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -1187,9 +1187,9 @@ public actor CommunityManager: CommunityManagerType { var result: Set = Set(room.admins + room.moderators) - if includingHidden else { - result.insert(contentsOf: room.hiddenAdmins ?? []) - result.insert(contentsOf: room.hiddenModerators ?? []) + if includingHidden { + result.insert(contentsOf: Set(room.hiddenAdmins ?? [])) + result.insert(contentsOf: Set(room.hiddenModerators ?? [])) } return result diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index f4c3618405..4a7cfa8a5a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -212,7 +212,7 @@ extension MessageReceiver { // If we received an outgoing message then we can assume the interaction has already // been sent, otherwise we should just use whatever the default state is state: (variant == .standardOutgoing ? .sent : nil), - isProMessage: isProMessage, + proFeatures: SessionPro.Features(rawValue: message.proFeatures ?? 0), using: dependencies ).inserted(db) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index fd97b65854..4ab67ea4f4 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -168,14 +168,7 @@ public final class MessageSender { case (_, .some(var messageWithProfile)): messageWithProfile.profile = dependencies .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } - .map { profile in - VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated - ) - } + .map { profile in VisibleMessage.VMProfile(profile: profile) } } // Convert and prepare the data for sending @@ -269,10 +262,7 @@ public final class MessageSender { } .map { profile, checkForCommunityMessageRequests in VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated, + profile: profile, blocksCommunityMessageRequests: !checkForCommunityMessageRequests ) } @@ -334,14 +324,7 @@ public final class MessageSender { case .some(var messageWithProfile): messageWithProfile.profile = dependencies .mutate(cache: .libSession) { $0.profile(contactId: userSessionId.hexString) } - .map { profile in - VisibleMessage.VMProfile( - displayName: profile.name, - profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl, - updateTimestampSeconds: profile.profileLastUpdated - ) - } + .map { profile in VisibleMessage.VMProfile(profile: profile) } default: break } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 0019ac35b0..35e80c18df 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -115,7 +115,7 @@ public actor SessionProManager: SessionProManagerType { ) } - nonisolated public func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage { + nonisolated public func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage { guard let cMessage: [CChar] = message.cString(using: .utf8) else { return SessionPro.FeaturesForMessage.invalidString } @@ -124,7 +124,7 @@ public actor SessionProManager: SessionProManagerType { session_protocol_pro_features_for_utf8( cMessage, (cMessage.count - 1), /// Need to `- 1` to avoid counting the null-termination character - extraFeatures.libSessionValue + features.libSessionValue ) ) } @@ -296,12 +296,12 @@ public protocol SessionProManagerType: SessionProUIManagerType { verifyPubkey: I?, atTimestampMs timestampMs: UInt64 ) -> SessionPro.ProStatus - nonisolated func features(for message: String, extraFeatures: SessionPro.ExtraFeatures) -> SessionPro.FeaturesForMessage func updateWithLatestFromUserConfig() async + nonisolated func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage } public extension SessionProManagerType { nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage { - return features(for: message, extraFeatures: .none) + return features(for: message, features: .none) } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift deleted file mode 100644 index 7a4d842592..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionNetworkingKit -import SessionUtilitiesKit - -public extension SessionPro { - struct ProConfig { - let rotatingPrivateKey: [UInt8] - let proProof: Network.SessionPro.ProProof - - init(_ libSessionValue: pro_pro_config) { - rotatingPrivateKey = libSessionValue.get(\.rotating_privkey) - proProof = Network.SessionPro.ProProof(libSessionValue.proof) - } - } -} - -extension pro_pro_config: @retroactive CAccessible {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift index c9650dd1d5..68dc5044f4 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -10,7 +10,7 @@ public extension SessionPro { let proProof: Network.SessionPro.ProProof let features: Features - public static let none: DecodedProForMessage = DecodedProForMessage( + public static let nonPro: DecodedProForMessage = DecodedProForMessage( status: .none, proProof: Network.SessionPro.ProProof(), features: .none diff --git a/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift deleted file mode 100644 index 72f96c1e04..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProExtraFeatures.swift +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil - -public extension SessionPro { - struct ExtraFeatures: OptionSet, Equatable, Hashable { - public let rawValue: UInt64 - - public static let none: ExtraFeatures = ExtraFeatures(rawValue: 0) - public static let proBadge: ExtraFeatures = ExtraFeatures(rawValue: 1 << 0) - public static let animatedAvatar: ExtraFeatures = ExtraFeatures(rawValue: 1 << 1) - public static let all: ExtraFeatures = [ proBadge, animatedAvatar ] - - var libSessionValue: SESSION_PROTOCOL_PRO_EXTRA_FEATURES { - SESSION_PROTOCOL_PRO_EXTRA_FEATURES(rawValue) - } - - // MARK: - Initialization - - public init(rawValue: UInt64) { - self.rawValue = rawValue - } - - init(_ libSessionValue: SESSION_PROTOCOL_PRO_EXTRA_FEATURES) { - self = ExtraFeatures(rawValue: libSessionValue) - } - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift index b642a2f478..f121b0f765 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtil public extension SessionPro { - struct Features: OptionSet, Sendable, Codable, Equatable, Hashable { + struct Features: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { public let rawValue: UInt64 public static let none: Features = Features(rawValue: 0) @@ -17,14 +17,36 @@ public extension SessionPro { SESSION_PROTOCOL_PRO_FEATURES(rawValue) } + var profileOnlyFeatures: Features { + self.subtracting(.largerCharacterLimit) + } + // MARK: - Initialization public init(rawValue: UInt64) { self.rawValue = rawValue } - init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES) { + public init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES) { self = Features(rawValue: libSessionValue) } + + // MARK: - CustomStringConvertible + + public var description: String { + var results: [String] = [] + + if self.contains(.largerCharacterLimit) { + results.append("largerCharacterLimit") + } + if self.contains(.proBadge) { + results.append("proBadge") + } + if self.contains(.animatedAvatar) { + results.append("animatedAvatar") + } + + return "[\(results.joined(separator: ", "))]" + } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift index 89f8a9084d..06112cb98f 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift @@ -6,10 +6,10 @@ import SessionUtilitiesKit public extension SessionPro { struct FeaturesForMessage: Equatable { - let status: FeatureStatus - let error: String? - let features: Features - let codePointCount: Int + public let status: FeatureStatus + public let error: String? + public let features: Features + public let codePointCount: Int static let invalidString: FeaturesForMessage = FeaturesForMessage(status: .utfDecodingError) diff --git a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift index 0eb3d7885a..a43ddccd6e 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift @@ -24,7 +24,7 @@ public extension SessionPro { } } - init(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { + public init(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { switch libSessionValue { case SESSION_PROTOCOL_PRO_STATUS_NIL: self = .none case SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG: self = .invalidProBackendSig diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 5eadc1325b..a10e607464 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -75,7 +75,6 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif public let mostRecentFailureText: String? public let isSenderModeratorOrAdmin: Bool public let canFollowDisappearingMessagesSetting: Bool - public let isProMessage: Bool // Display Properties @@ -172,7 +171,6 @@ public extension MessageViewModel { self.mostRecentFailureText = nil self.isSenderModeratorOrAdmin = false self.canFollowDisappearingMessagesSetting = false - self.isProMessage = false self.shouldShowAuthorName = false self.canHaveProfile = false @@ -245,6 +243,14 @@ public extension MessageViewModel { profileCache: profileCache, using: dependencies ) + let proFeatures: SessionPro.Features = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return interaction.proFeatures + .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) + .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) + .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) + }() self.cellType = MessageViewModel.cellType( interaction: interaction, @@ -267,7 +273,7 @@ public extension MessageViewModel { self.expiresInSeconds = interaction.expiresInSeconds self.attachments = attachments self.reactionInfo = (reactionInfo ?? []) - self.profile = (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) // TODO: [PRO] Do we want this???. + self.profile = (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) self.quotedInfo = quotedInteraction.map { quotedInteraction -> QuotedInfo? in guard let quoteInteractionId: Int64 = quotedInteraction.id else { return nil } @@ -278,6 +284,14 @@ public extension MessageViewModel { linkPreviewCache: linkPreviewCache, attachmentCache: attachmentCache ) + let quotedInteractionProFeatures: SessionPro.Features = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return quotedInteraction.proFeatures + .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) + .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) + .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) + }() return MessageViewModel.QuotedInfo( interactionId: quoteInteractionId, @@ -305,12 +319,12 @@ public extension MessageViewModel { using: dependencies ), attachment: (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment), - proFeatures: .none // TODO: [PRO] Need to get this from the message + proFeatures: quotedInteractionProFeatures ) } self.linkPreview = linkPreviewInfo?.preview self.linkPreviewAttachment = linkPreviewInfo?.attachment - self.proFeatures = .none // TODO: [PRO] Need to get this from the message + self.proFeatures = proFeatures self.authorName = authorDisplayName self.authorNameSuppressedId = { @@ -349,7 +363,6 @@ public extension MessageViewModel { ) ) }() - self.isProMessage = false // TODO: [PRO] Need to replace this. let isGroupThread: Bool = ( threadVariant == .community || @@ -492,7 +505,6 @@ public extension MessageViewModel { mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), isSenderModeratorOrAdmin: isSenderModeratorOrAdmin, canFollowDisappearingMessagesSetting: canFollowDisappearingMessagesSetting, - isProMessage: isProMessage, shouldShowAuthorName: shouldShowAuthorName, canHaveProfile: canHaveProfile, shouldShowDisplayPicture: shouldShowDisplayPicture, diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index ab5571aaef..73679ed80e 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -199,6 +199,12 @@ public struct ProfileEvent: Hashable { case name(String) case nickname(String?) case displayPictureUrl(String?) + case proStatus( + isPro: Bool, + features: SessionPro.Features, + proExpiryUnixTimestampMs: UInt64, + proGenIndexHash: String? + ) } } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 84604d2adc..0003e24639 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -241,12 +241,80 @@ public extension Profile { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) } } - - // TODO: [PRO] Handle Pro Proof update /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } + + /// Session Pro Information + let proInfo: SessionPro.DecodedProForMessage = (decodedPro ?? .nonPro) + + switch proInfo.status { + case .valid: + let originalChangeCount: Int = profileChanges.count + let finalFeatures: SessionPro.Features = proInfo.features.profileOnlyFeatures + + if profile.proFeatures != finalFeatures { + updatedProfile = updatedProfile.with(proFeatures: .set(to: finalFeatures)) + profileChanges.append(Profile.Columns.proFeatures.set(to: finalFeatures.rawValue)) + } + + if profile.proExpiryUnixTimestampMs != proInfo.proProof.expiryUnixTimestampMs { + let value: UInt64 = proInfo.proProof.expiryUnixTimestampMs + updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: value)) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: value)) + } + + if profile.proGenIndexHash != proInfo.proProof.genIndexHash.toHexString() { + let value: String = proInfo.proProof.genIndexHash.toHexString() + updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: value)) + profileChanges.append(Profile.Columns.proGenIndexHash.set(to: value)) + } + + /// If the change count no longer matches then the pro status was updated so we need to emit an event + if profileChanges.count != originalChangeCount { + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: true, + features: finalFeatures, + proExpiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, + proGenIndexHash: proInfo.proProof.genIndexHash.toHexString() + ) + ) + } + + default: + let originalChangeCount: Int = profileChanges.count + + if profile.proFeatures != .none { + updatedProfile = updatedProfile.with(proFeatures: .set(to: .none)) + profileChanges.append(Profile.Columns.proFeatures.set(to: .none)) + } + + if profile.proExpiryUnixTimestampMs > 0 { + updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: 0)) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: 0)) + } + + if profile.proGenIndexHash != nil { + updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: nil)) + profileChanges.append(Profile.Columns.proGenIndexHash.set(to: nil)) + } + + /// If the change count no longer matches then the pro status was updated so we need to emit an event + if profileChanges.count != originalChangeCount { + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: false, + features: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHash: nil + ) + ) + } + } } /// Nickname - this is controlled by the current user so should always be used @@ -339,6 +407,9 @@ public extension Profile { case .nickname(let nickname): return (nickname != nil ? "nickname updated" : "nickname removed") // stringlint:ignore + + case .proStatus(let isPro, let features, _, _): + return "pro state - \(isPro ? "enabled: \(features)" : "disabled")" // stringlint:ignore } } .joined(separator: ", ") @@ -360,6 +431,7 @@ public extension Profile { if !suppressUserProfileConfigUpdate, isCurrentUser { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in + // TODO: [PRO] Need to update the current users pro settings? try cache.updateProfile( displayName: .set(to: updatedProfile.name), displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index e4f6c4dff0..70deaa8b5e 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -82,7 +82,9 @@ public extension Network.SessionPro { cRotatingPrivateKey.count, PaymentProvider.appStore.libSessionValue, cTransactionId, - cTransactionId.count + cTransactionId.count, + [], /// The `order_id` is only needed for Google transactions + 0 ) ) @@ -95,7 +97,8 @@ public extension Network.SessionPro { rotatingPublicKey: rotatingKeyPair.publicKey, paymentTransaction: UserTransaction( provider: .appStore, - paymentId: transactionId + paymentId: transactionId, + orderId: "" /// The `order_id` is only needed for Google transactions ), signatures: signatures ), diff --git a/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift b/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift new file mode 100644 index 0000000000..8ee175bc1d --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/AddProPaymentResponseStatus.swift @@ -0,0 +1,35 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension Network.SessionPro { + enum AddProPaymentResponseStatus: CaseIterable { + case success + case error + case parseError + case alreadyRedeemed + case unknownPayment + + var libSessionValue: SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS { + switch self { + case .success: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS + case .error: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR + case .parseError: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR + case .alreadyRedeemed: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED + case .unknownPayment: return SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT + } + } + + init(_ libSessionValue: SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS) { + switch libSessionValue { + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS: self = .success + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR: self = .error + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR: self = .parseError + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED: self = .alreadyRedeemed + case SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT: self = .unknownPayment + default: self = .error + } + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift index b916612e48..56d00d815f 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProProof.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -6,11 +6,11 @@ import SessionUtilitiesKit public extension Network.SessionPro { struct ProProof: Sendable, Codable, Equatable { - let version: UInt8 - let genIndexHash: [UInt8] - let rotatingPubkey: [UInt8] - let expiryUnixTimestampMs: UInt64 - let signature: [UInt8] + public let version: UInt8 + public let genIndexHash: [UInt8] + public let rotatingPubkey: [UInt8] + public let expiryUnixTimestampMs: UInt64 + public let signature: [UInt8] public var libSessionValue: session_protocol_pro_proof { var result: session_protocol_pro_proof = session_protocol_pro_proof() diff --git a/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift b/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift index 650c26a167..6a48b68e03 100644 --- a/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift +++ b/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift @@ -8,17 +8,20 @@ public extension Network.SessionPro { struct UserTransaction: Equatable { public let provider: PaymentProvider public let paymentId: String + public let orderId: String // MARK: - Initialization - init (provider: PaymentProvider, paymentId: String) { + init (provider: PaymentProvider, paymentId: String, orderId: String) { self.provider = provider self.paymentId = paymentId + self.orderId = orderId } init(_ libSessionValue: session_pro_backend_add_pro_payment_user_transaction) { provider = PaymentProvider(libSessionValue.provider) paymentId = libSessionValue.get(\.payment_id).substring(to: libSessionValue.payment_id_count) + orderId = libSessionValue.get(\.order_id).substring(to: libSessionValue.order_id_count) } // MARK: - Functions @@ -28,6 +31,8 @@ public extension Network.SessionPro { result.provider = provider.libSessionValue result.set(\.payment_id, to: paymentId) result.payment_id_count = paymentId.count + result.set(\.order_id, to: orderId) + result.order_id_count = orderId.count return result } diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index ed2867848a..628527ad38 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -240,7 +240,8 @@ class DatabaseSpec: QuickSpec { "messagingKit.AddProMessageFlag", "LastProfileUpdateTimestamp", "RemoveQuoteUnusedColumnsAndForeignKeys", - "DropUnneededColumnsAndTables" + "DropUnneededColumnsAndTables", + "SessionProChanges" ])) } diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 1939c19c16..cc7472f8d8 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -162,7 +162,7 @@ public struct UserProfileModal: View { .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.center) - if info.isProUser { + if info.shouldShowProBadge { SessionProBadge_SwiftUI(size: .large) .onTapGesture { info.onProBadgeTapped?() @@ -171,7 +171,7 @@ public struct UserProfileModal: View { } if let contactDisplayName: String = info.contactDisplayName, contactDisplayName != displayName { - Text("(\(contactDisplayName))") // stringlint:ignroe + Text("(\(contactDisplayName))") // stringlint:ignore .font(.Body.smallRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) @@ -405,7 +405,7 @@ public extension UserProfileModal { let profileInfo: ProfilePictureView.Info let displayName: String? let contactDisplayName: String? - let isProUser: Bool + let shouldShowProBadge: Bool let isMessageRequestsEnabled: Bool let onStartThread: (() -> Void)? let onProBadgeTapped: (() -> Void)? @@ -417,7 +417,7 @@ public extension UserProfileModal { profileInfo: ProfilePictureView.Info, displayName: String?, contactDisplayName: String?, - isProUser: Bool, + shouldShowProBadge: Bool, isMessageRequestsEnabled: Bool, onStartThread: (() -> Void)?, onProBadgeTapped: (() -> Void)? @@ -428,7 +428,7 @@ public extension UserProfileModal { self.profileInfo = profileInfo self.displayName = displayName self.contactDisplayName = contactDisplayName - self.isProUser = isProUser + self.shouldShowProBadge = shouldShowProBadge self.isMessageRequestsEnabled = isMessageRequestsEnabled self.onStartThread = onStartThread self.onProBadgeTapped = onProBadgeTapped diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index fb7fc2b18e..48ccebae82 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -90,20 +90,20 @@ public extension FeatureStorage { identifier: "sessionPro" ) - static let allUsersSessionPro: FeatureConfig = Dependencies.create( - identifier: "allUsersSessionPro" + static let proBadgeEverywhere: FeatureConfig = Dependencies.create( + identifier: "proBadgeEverywhere" ) - static let messageFeatureProBadge: FeatureConfig = Dependencies.create( - identifier: "messageFeatureProBadge" + static let forceMessageFeatureProBadge: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureProBadge" ) - static let messageFeatureLongMessage: FeatureConfig = Dependencies.create( - identifier: "messageFeatureLongMessage" + static let forceMessageFeatureLongMessage: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureLongMessage" ) - static let messageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( - identifier: "messageFeatureAnimatedAvatar" + static let forceMessageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( + identifier: "forceMessageFeatureAnimatedAvatar" ) static let shortenFileTTL: FeatureConfig = Dependencies.create( diff --git a/SessionUtilitiesKit/Utilities/Version.swift b/SessionUtilitiesKit/Utilities/Version.swift index 0ef50ac78a..cd1adc1ac2 100644 --- a/SessionUtilitiesKit/Utilities/Version.swift +++ b/SessionUtilitiesKit/Utilities/Version.swift @@ -57,7 +57,7 @@ public struct Version: Comparable { } } -public enum FeatureVersion: Int, Codable, Equatable, Hashable, DatabaseValueConvertible { +public enum FeatureVersion: Int, Sendable, Codable, Equatable, Hashable, DatabaseValueConvertible { case legacyDisappearingMessages case newDisappearingMessages } From aa203decc58ec41de9257f7f279f32c3b08df915 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 14 Nov 2025 06:45:03 +1100 Subject: [PATCH 17/66] Updated the remaining Pro Badge cases --- Session.xcodeproj/project.pbxproj | 4 ---- .../Closed Groups/EditGroupViewModel.swift | 9 ++++++-- .../ConversationVC+Interaction.swift | 4 ++-- .../Settings/BlockedContactsViewModel.swift | 9 ++++++-- Session/Shared/UserListViewModel.swift | 9 ++++++-- .../Config Handling/LibSession+Pro.swift | 22 ------------------- .../ProfilePictureView+Convenience.swift | 3 +-- 7 files changed, 24 insertions(+), 36 deletions(-) delete mode 100644 SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 08fd4e972e..5e5f0a8856 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -211,7 +211,6 @@ 94B6BB072E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */; }; - 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BC2E0908340097754D /* LibSession+Pro.swift */; }; 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */; }; 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962B2E1B85920097754D /* InputViewButton.swift */; }; 94CD962E2E1B85920097754D /* InputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD962A2E1B85920097754D /* InputTextView.swift */; }; @@ -1644,7 +1643,6 @@ 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTAAnimationCropped.webp; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProBadge.swift; sourceTree = ""; }; - 94CD95BC2E0908340097754D /* LibSession+Pro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+Pro.swift"; sourceTree = ""; }; 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _044_AddProMessageFlag.swift; sourceTree = ""; }; 94CD962A2E1B85920097754D /* InputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextView.swift; sourceTree = ""; }; 94CD962B2E1B85920097754D /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = ""; }; @@ -5007,7 +5005,6 @@ FD8ECF8E29381FB200C0D1BB /* Config Handling */ = { isa = PBXGroup; children = ( - 94CD95BC2E0908340097754D /* LibSession+Pro.swift */, FD2272F32C352D8D004D8A6C /* LibSession+Contacts.swift */, FD2272F42C352D8D004D8A6C /* LibSession+ConvoInfoVolatile.swift */, FD2272F92C352D8E004D8A6C /* LibSession+GroupInfo.swift */, @@ -7050,7 +7047,6 @@ FD2272FF2C352D8E004D8A6C /* LibSession+UserProfile.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, - 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */, FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */, FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index c3378090e6..0d9848fb25 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -302,8 +302,13 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa identifier: "Contact" ), trailingImage: { - guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) else { return nil } - return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + guard memberInfo.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } + + return ("ProBadge", { [dependencies] in + SessionProBadge(size: .small).toImage(using: dependencies) + }) }() ), subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo( diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index eae14bc705..53a13841fe 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1736,14 +1736,14 @@ extension ConversationVC: self.hideInputAccessoryView() let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( - info: .init( + info: UserProfileModal.Info( sessionId: sessionId, blindedId: blindedId, qrCodeImage: qrCodeImage, profileInfo: profileInfo, displayName: displayName, contactDisplayName: contactDisplayName, - isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), + shouldShowProBadge: cellViewModel.profile.proFeatures.contains(.proBadge), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 95d766688f..529655f3b4 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -301,8 +301,13 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo (model.profile?.displayName() ?? model.id.truncated()), font: .title, trailingImage: { - guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) else { return nil } - return ("ProBadge", { [dependencies = viewModel.dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + guard model.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } + + return ("ProBadge", { [dependencies = viewModel.dependencies] in + SessionProBadge(size: .small).toImage(using: dependencies) + }) }() ), trailingAccessory: .radio( diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index e1de24bbb8..720b05b615 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -153,8 +153,13 @@ class UserListViewModel: SessionTableVie title, font: .title, trailingImage: { - guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) else { return nil } - return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + guard userInfo.profile?.proFeatures.contains(.proBadge) == true else { + return nil + } + + return ("ProBadge", { [dependencies] in + SessionProBadge(size: .small).toImage(using: dependencies) + }) }() ), subtitle: SessionCell.TextInfo(userInfo.itemDescription(using: dependencies), font: .subtitle), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift deleted file mode 100644 index a4c8cce9c8..0000000000 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtil -import SessionUtilitiesKit - -// MARK: - Session Pro -// TODO: Implementation - -public extension LibSessionCacheType { - - func validateProProof(for message: Message?) -> Bool { - guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .proBadgeEverywhere] - } - - func validateProProof(for profile: Profile?) -> Bool { - guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .proBadgeEverywhere] - } -} diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index b109b892dc..39b27914ee 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -202,8 +202,7 @@ public extension ProfilePictureView { case .some(let profile) where profile.id == dependencies[cache: .general].sessionId.hexString: return .currentUser(dependencies[singleton: .sessionProManager]) - case .some(let profile): - return .contact(dependencies.mutate(cache: .libSession, { $0.validateProProof(for: profile) })) + case .some(let profile): return .contact(profile.proFeatures.contains(.animatedAvatar) == true) } } } From 4ead1e8a1cdf640988e04c42a7ed74053a29c40f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 14 Nov 2025 08:58:27 +1100 Subject: [PATCH 18/66] Updates to dev settings and global search fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Updated the dev settings to show the different products • Fixed an issue where opening a conversation from global search would result in data missing --- .../ConversationVC+Interaction.swift | 11 +- .../Conversations/ConversationViewModel.swift | 38 ++++++ .../Settings/ThreadSettingsViewModel.swift | 5 +- .../GlobalSearchViewController.swift | 17 ++- Session/Meta/SessionApp.swift | 27 +--- .../DeveloperSettingsProViewModel.swift | 118 ++++++++++++++++-- .../Open Groups/CommunityManager.swift | 9 ++ 7 files changed, 180 insertions(+), 45 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 53a13841fe..bd7fed226c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -803,17 +803,16 @@ extension ConversationVC: } private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async { - let threadId: String = self.viewModel.state.threadId - let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant + let state: ConversationViewModel.State = self.viewModel.state // Actually send the message do { try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if self?.viewModel.state.threadViewModel.threadShouldBeVisible == false { + if state.threadViewModel.threadShouldBeVisible == false { try SessionThread.updateVisibility( db, - threadId: threadId, + threadId: state.threadId, isVisible: true, additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], using: dependencies @@ -907,8 +906,8 @@ extension ConversationVC: try MessageSender.send( db, interaction: insertedInteraction, - threadId: threadId, - threadVariant: threadVariant, + threadId: state.threadId, + threadVariant: state.threadVariant, using: dependencies ) } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index f4ed2da506..05052e052e 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1880,6 +1880,44 @@ private extension ConversationTitleViewModel { // MARK: - Convenience public extension ConversationViewModel { + static func fetchThreadViewModel( + threadId: String, + variant: SessionThread.Variant, + using dependencies: Dependencies + ) async throws -> SessionThreadViewModel { + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard variant == .group else { return (false, false) } + + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)), + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + ) + } + }() + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let currentUserSessionIds: Set = await { + guard + variant == .community, + let serverInfo: CommunityManager.Server = await dependencies[singleton: .communityManager].server(threadId: threadId) + else { return [userSessionId.hexString] } + + return serverInfo.currentUserSessionIds + }() + + return try await dependencies[singleton: .storage].readAsync { [dependencies] db in + try ConversationViewModel.fetchThreadViewModel( + db, + threadId: threadId, + userSessionId: userSessionId, + currentUserSessionIds: currentUserSessionIds, + threadWasKickedFromGroup: wasKickedFromGroup, + threadGroupIsDestroyed: groupIsDestroyed, + using: dependencies + ) + } + } + static func fetchThreadViewModel( _ db: ObservingDatabase, threadId: String, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index d75f92b478..a58b952807 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -315,7 +315,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi current.conversationHasProEnabled && !threadViewModel.threadIsNoteToSelf else { return nil } - return ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) + + return ("ProBadge", { [dependencies] in + SessionProBadge(size: .medium).toImage(using: dependencies) + }) }() ), styling: SessionCell.StyleInfo( diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index b98d0ccdec..08930798a4 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -445,8 +445,7 @@ extension GlobalSearchViewController { focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true ) async { - // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the - // contact has been hidden) + /// If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the contact has been hidden) if threadViewModel.threadVariant == .contact { _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try SessionThread.upsert( @@ -459,9 +458,21 @@ extension GlobalSearchViewController { } } + /// Need to fetch the "full" data for the conversation screen + let maybeThreadViewModel: SessionThreadViewModel? = try? await ConversationViewModel.fetchThreadViewModel( + threadId: threadViewModel.threadId, + variant: threadViewModel.threadVariant, + using: dependencies + ) + + guard let finalThreadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + Log.error("Failed to present \(threadViewModel.threadVariant) conversation \(threadViewModel.threadId) due to failure to fetch threadViewModel") + return + } + await MainActor.run { let viewController: ConversationVC = ConversationVC( - threadViewModel: threadViewModel, + threadViewModel: finalThreadViewModel, focusedInteractionInfo: focusedInteractionInfo, using: dependencies ) diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index 4a2cb0d873..3e38278a12 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -103,28 +103,11 @@ public class SessionApp: SessionAppType { } } - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard variant == .group else { return (false, false) } - - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)), - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - ) - } - }() - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let maybeThreadViewModel: SessionThreadViewModel? = try? await dependencies[singleton: .storage].readAsync { [dependencies] db in - try ConversationViewModel.fetchThreadViewModel( - db, - threadId: threadId, - userSessionId: userSessionId, - currentUserSessionIds: [userSessionId.hexString], - threadWasKickedFromGroup: wasKickedFromGroup, - threadGroupIsDestroyed: groupIsDestroyed, - using: dependencies - ) - } + let maybeThreadViewModel: SessionThreadViewModel? = try? await ConversationViewModel.fetchThreadViewModel( + threadId: threadId, + variant: variant, + using: dependencies + ) guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { Log.error("Failed to present \(variant) conversation \(threadId) due to failure to fetch threadViewModel") diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index e318796838..c3e5d9e85e 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -324,7 +324,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } }() - var features: SectionModel = SectionModel( + let features: SectionModel = SectionModel( model: .features, elements: [ SessionCell.Info( @@ -467,7 +467,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold """, trailingAccessory: .highlightingBackgroundLabel(title: "Purchase"), onTap: { [weak viewModel] in - Task { await viewModel?.purchaseSubscription() } + Task { await viewModel?.purchaseSubscription(currentProduct: state.purchasedProduct) } } ), SessionCell.Info( @@ -638,20 +638,83 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold // MARK: - Pro Requests - private func purchaseSubscription() async { + private func purchaseSubscription(currentProduct: Product?) async { do { - let products: [Product] = try await Product.products(for: ["com.getsession.org.pro_sub"]) + let products: [Product] = try await Product.products(for: [ + "com.getsession.org.pro_sub", + "com.getsession.org.pro_sub_1_month", + "com.getsession.org.pro_sub_3_months", + "com.getsession.org.pro_sub_12_months" + ]) - guard let product: Product = products.first else { - Log.error("[DevSettings] Unable to purchase subscription due to error: No products found") - dependencies.notifyAsync( - key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.purchasedProduct([], nil, "No products found", nil, nil) + await MainActor.run { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Purchase", + body: .radio( + explanation: ThemedAttributedString( + string: "Please select the subscription to purchaase." + ), + warning: nil, + options: products.sorted().map { product in + ConfirmationModal.Info.Body.RadioOptionInfo( + title: "\(product.displayName), price: \(product.displayPrice)", + descriptionText: ThemedAttributedString( + stringWithHTMLTags: product.description, + font: RadioButton.descriptionFont + ), + enabled: true, + selected: currentProduct?.id == product.id + ) + } + ), + confirmTitle: "select".localized(), + cancelStyle: .alert_text, + onConfirm: { [weak self] modal in + let selectedProduct: Product? = { + switch modal.info.body { + case .radio(_, _, let options): + return options + .enumerated() + .first(where: { _, value in value.selected }) + .map { index, _ in + guard index >= 0 && (index - 1) < products.count else { + return nil + } + + return products[index] + } + + default: return nil + } + }() + + if let product: Product = selectedProduct { + Task(priority: .userInitiated) { [weak self] in + await self?.confirmPurchase(products: products, product: product) + } + } + } + ) + ), + transitionType: .present ) - return } - + } + catch { + Log.error("[DevSettings] Unable to purchase subscription due to error: \(error)") + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.purchasedProduct([], nil, "Failed: \(error)", nil, nil) + ) + } + } + + private func confirmPurchase(products: [Product], product: Product) async { + do { let result = try await product.purchase() + switch result { case .success(let verificationResult): let transaction = try verificationResult.payloadValue @@ -691,7 +754,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } private func manageSubscriptions() async { - guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + guard let scene: UIWindowScene = await UIApplication.shared.connectedScenes.first as? UIWindowScene else { return Log.error("[DevSettings] Unable to show manage subscriptions: Unable to get UIWindowScene") } @@ -714,7 +777,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func requestRefund() async { guard let transaction: Transaction = await internalState.purchaseTransaction else { return } - guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + guard let scene: UIWindowScene = await UIApplication.shared.connectedScenes.first as? UIWindowScene else { return Log.error("[DevSettings] Unable to show manage subscriptions: Unable to get UIWindowScene") } @@ -816,3 +879,32 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } } + +extension Product: @retroactive Comparable { + public static func < (lhs: Product, rhs: Product) -> Bool { + guard + let lhsSubscription: SubscriptionInfo = lhs.subscription, + let rhsSubscription: SubscriptionInfo = rhs.subscription, ( + lhsSubscription.subscriptionPeriod.unit != rhsSubscription.subscriptionPeriod.unit || + lhsSubscription.subscriptionPeriod.value != rhsSubscription.subscriptionPeriod.value + ) + else { return lhs.id < rhs.id } + + func approximateDurationDays(_ subscription: SubscriptionInfo) -> Int { + switch subscription.subscriptionPeriod.unit { + case .day: return subscription.subscriptionPeriod.value + case .week: return subscription.subscriptionPeriod.value * 7 + case .month: return subscription.subscriptionPeriod.value * 30 + case .year: return subscription.subscriptionPeriod.value * 365 + @unknown default: return subscription.subscriptionPeriod.value + } + } + + let lhsApproxDays: Int = approximateDurationDays(lhsSubscription) + let rhsApproxDays: Int = approximateDurationDays(rhsSubscription) + + guard lhsApproxDays != rhsApproxDays else { return lhs.id < rhs.id } + + return (lhsApproxDays < rhsApproxDays) + } +} diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index f2f8f77892..e78cdfeb9b 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -134,6 +134,14 @@ public actor CommunityManager: CommunityManagerType { return _servers[server.lowercased()] } + public func server(threadId: String) async -> Server? { + return _servers.values.first { server in + return server.rooms.values.contains { + OpenGroup.idFor(roomToken: $0.token, server: server.server) == threadId + } + } + } + public func updateServer(server: Server) async { _servers[server.server.lowercased()] = server } @@ -1287,6 +1295,7 @@ public protocol CommunityManagerType { func loadCacheIfNeeded() async func server(_ server: String) async -> CommunityManager.Server? + func server(threadId: String) async -> CommunityManager.Server? func updateServer(server: CommunityManager.Server) async func updateCapabilities( capabilities: Set, From 58329d2e9d89af032b4186d3ac09dbb40cee2679 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 14 Nov 2025 11:43:08 +1100 Subject: [PATCH 19/66] Resolved a bunch of TODOs and cleaned up some of the interface --- .../Closed Groups/EditGroupViewModel.swift | 2 +- .../ConversationVC+Interaction.swift | 22 --- .../Conversations/ConversationViewModel.swift | 18 +- .../Settings/ThreadSettingsViewModel.swift | 10 +- .../MessageInfoScreen.swift | 1 + Session/Onboarding/Onboarding.swift | 5 +- .../Settings/BlockedContactsViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 4 +- Session/Shared/UserListViewModel.swift | 2 +- .../Crypto/Crypto+LibSession.swift | 29 ++- .../Jobs/MessageReceiveJob.swift | 181 ++++++++++-------- .../Config Handling/LibSession+Contacts.swift | 3 + .../LibSession+GroupMembers.swift | 4 +- .../LibSession+UserProfile.swift | 3 + .../Open Groups/CommunityManager.swift | 34 +++- .../MessageReceiver+Groups.swift | 22 ++- .../MessageReceiver+MessageRequests.swift | 9 +- .../MessageReceiver+VisibleMessages.swift | 9 +- .../Sending & Receiving/MessageReceiver.swift | 4 + .../Pollers/CommunityPoller.swift | 16 +- .../Pollers/SwarmPoller.swift | 4 +- .../SessionPro/Types/SessionProFeatures.swift | 1 + .../Shared Models/MessageViewModel.swift | 74 ++++--- .../Utilities/DisplayPictureManager.swift | 10 +- .../Utilities/Profile+Updating.swift | 159 ++++++++------- SessionUIKit/Components/SessionProBadge.swift | 2 + 26 files changed, 371 insertions(+), 259 deletions(-) diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 0d9848fb25..f0b2a86fd6 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -306,7 +306,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa return nil } - return ("ProBadge", { [dependencies] in + return (SessionProBadge.identifier, { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) }() diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index bd7fed226c..ccfe4e8356 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -881,28 +881,6 @@ extension ConversationVC: toInteractionWithId: insertedInteraction.id ) - // If we are sending a blinded message then we need to update the blinded profile - // information to ensure the name is up to date (as it won't be updated otherwise - // because the message would get deduped when fetched from the poller) - // FIXME: Remove this once we don't generate unique Profile entries for the current users blinded ids - if (try? SessionId.Prefix(from: optimisticData.interaction.authorId)) != .standard { - let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - - try? Profile.updateIfNeeded( - db, - publicKey: optimisticData.interaction.authorId, - displayNameUpdate: .contactUpdate(currentUserProfile.name), - displayPictureUpdate: DisplayPictureManager.Update.from( - currentUserProfile, - fallback: .none, - using: dependencies - ), - decodedPro: dependencies[singleton: .sessionProManager].currentUserCurrentDecodedProForMessage, - profileUpdateTimestamp: currentUserProfile.profileLastUpdated, - using: dependencies - ) - } - try MessageSender.send( db, interaction: insertedInteraction, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 05052e052e..8a93997cc6 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -919,7 +919,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) }() - profileCache[profile.id] = profile + profileCache[profile.id] = profile.with(proFeatures: .set(to: finalFeatures)) } fetchedLinkPreviews.forEach { linkPreviewCache[$0.url, default: []].append($0) } fetchedAttachments.forEach { attachmentCache[$0.id] = $0 } @@ -1088,10 +1088,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold optimisticMessageId = nil interaction = targetInteraction reactionInfo = reactionCache[id].map { reactions in - reactions.map { - MessageViewModel.ReactionInfo( - reaction: $0, - profile: profileCache[$0.authorId] + reactions.map { reaction in + /// If the reactor is the current user then use the proper profile from the cache (instead of a random + /// blinded one) + let targetId: String = (currentUserSessionIds.contains(reaction.authorId) ? + previousState.userSessionId.hexString : + reaction.authorId + ) + + return MessageViewModel.ReactionInfo( + reaction: reaction, + profile: profileCache[targetId] ) } } @@ -1112,6 +1119,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold linkPreviewCache: linkPreviewCache, attachmentMap: attachmentMap, isSenderModeratorOrAdmin: modAdminCache.contains(interaction.authorId), + userSessionId: previousState.userSessionId, currentUserSessionIds: currentUserSessionIds, previousInteraction: State.interaction( at: index + 1, /// Order is inverted so `previousInteraction` is the next element diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index a58b952807..563aff89c8 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -316,7 +316,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi !threadViewModel.threadIsNoteToSelf else { return nil } - return ("ProBadge", { [dependencies] in + return (SessionProBadge.identifier, { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) }() @@ -1591,6 +1591,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry /// about retrieving them in the confirmation closure self.updatedName = current + let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId + return ConfirmationModal.Info( title: "nicknameSet".localized(), body: .input( @@ -1645,7 +1647,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi db, publicKey: threadId, nicknameUpdate: .set(to: finalNickname), - profileUpdateTimestamp: nil, + profileUpdateTimestamp: nil, /// Not set for `nickname` + currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread using: dependencies ) }, @@ -1664,7 +1667,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi db, publicKey: threadId, nicknameUpdate: .set(to: nil), - profileUpdateTimestamp: nil, + profileUpdateTimestamp: nil, /// Not set for `nickname` + currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread using: dependencies ) }, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 8225fd8b52..c137c1c3ae 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -917,6 +917,7 @@ struct MessageInfoView_Previews: PreviewProvider { linkPreviewCache: [:], attachmentMap: [:], isSenderModeratorOrAdmin: false, + userSessionId: SessionId(.standard, hex: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a961111"), currentUserSessionIds: ["d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg"], previousInteraction: nil, nextInteraction: nil, diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 39d4efbfc6..30cbe16103 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -412,10 +412,9 @@ extension Onboarding { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(displayName), displayPictureUpdate: .none, - // TODO: [PRO] Need to decide if this is accurate - /// We won't have the current users Session Pro state at this stage - decodedPro: nil, + proUpdate: .none, profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 529655f3b4..1faaa0103e 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -305,7 +305,7 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo return nil } - return ("ProBadge", { [dependencies = viewModel.dependencies] in + return (SessionProBadge.identifier, { [dependencies = viewModel.dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) }() diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 93d8563d20..c99f12a77f 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -348,7 +348,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl guard state.sessionProBackendStatus == .active else { return nil } return ( - "ProBadge", + SessionProBadge.identifier, { SessionProBadge(size: .medium).toImage(using: viewModel.dependencies) } ) }() @@ -909,7 +909,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } @MainActor fileprivate func updateProfile( - displayNameUpdate: Profile.DisplayNameUpdate = .none, + displayNameUpdate: Profile.TargetUserUpdate = .none, displayPictureUpdateGenerator generator: @escaping () async throws -> DisplayPictureManager.Update = { .none }, onComplete: @escaping () -> () ) { diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index 720b05b615..9f586aad1d 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -157,7 +157,7 @@ class UserListViewModel: SessionTableVie return nil } - return ("ProBadge", { [dependencies] in + return (SessionProBadge.identifier, { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) }() diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index fd2965bc60..06c42a2727 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -139,6 +139,9 @@ public extension Crypto.Generator { switch origin { case .community(_, let sender, let posted, _, _, _, _): + /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of + /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy + /// structure) then we try to decode as the legacy structure (which succeeds) var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( cEncodedMessage, cEncodedMessage.count, @@ -167,14 +170,28 @@ public extension Crypto.Generator { serverPublicKey: serverPublicKey ) ) - let plaintext: Data = plaintextWithPadding.removePadding() + let cPlaintext: [UInt8] = Array(plaintextWithPadding.removePadding()) - return DecodedMessage( - content: plaintext, - sender: try SessionId(from: senderId), - decodedEnvelope: nil, // TODO: [PRO] If we don't set this then we won't know the pro status - sentTimestampMs: UInt64(floor(posted * 1000)) + /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of + /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy + /// structure) then we try to decode as the legacy structure (which succeeds) + var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( + cPlaintext, + cPlaintext.count, + currentTimestampMs, + cBackendPubkey, + cBackendPubkey.count, + &error, + error.count ) + defer { session_protocol_decode_for_community_free(&cResult) } + + guard cResult.success else { + Log.error(.messageSender, "Failed to decode community message due to error: \(String(cString: error))") + throw MessageError.decodingFailed + } + + return try DecodedMessage(decodedValue: cResult, sender: sender, posted: posted) case .swarm(let publicKey, let namespace, _, _, _): /// Function to provide pointers to the keys based on the namespace the message was received from diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index beb35792fe..4d55c0fad7 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -32,91 +32,116 @@ public enum MessageReceiveJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - var updatedJob: Job = job - var lastError: Error? - var remainingMessagesToProcess: [Details.MessageInfo] = [] - - dependencies[singleton: .storage].writeAsync( - updates: { db -> Error? in - for messageInfo in details.messages { - do { - let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( - db, - threadId: threadId, - threadVariant: messageInfo.threadVariant, - message: messageInfo.message, - decodedMessage: messageInfo.decodedMessage, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - suppressNotifications: false, - using: dependencies - ) - - /// Notify about the received message - MessageReceiver.prepareNotificationsForInsertedInteractions( - db, - insertedInteractionInfo: info, - isMessageRequest: dependencies.mutate(cache: .libSession) { cache in - cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) - }, - using: dependencies - ) + Task { + typealias Result = ( + updatedJob: Job, + lastError: Error?, + remainingMessagesToProcess: [Details.MessageInfo] + ) + + do { + let currentUserSessionIds: Set = try await { + switch details.messages.first?.threadVariant { + case .none: throw JobRunnerError.missingRequiredDetails + case .contact, .group, .legacyGroup: + return [dependencies[cache: .general].sessionId.hexString] + + case .community: + guard let server: CommunityManager.Server = await dependencies[singleton: .communityManager].server(threadId: threadId) else { + return [dependencies[cache: .general].sessionId.hexString] + } + + return server.currentUserSessionIds } - catch { - // If the current message is a permanent failure then override it with the - // new error (we want to retry if there is a single non-permanent error) - switch error { - // Ignore duplicate and self-send errors (these will usually be caught during - // parsing but sometimes can get past and conflict at database insertion - eg. - // for open group messages) we also don't bother logging as it results in - // excessive logging which isn't useful) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE - MessageError.duplicateMessage, - MessageError.selfSend: - break - - case is MessageError: - Log.error(.cat, "Permanently failed message due to error: \(error)") - continue - - default: - Log.error(.cat, "Couldn't receive message due to error: \(error)") - lastError = error - - // We failed to process this message but it is a retryable error - // so add it to the list to re-process - remainingMessagesToProcess.append(messageInfo) + }() + + let result: Result = try await dependencies[singleton: .storage].writeAsync { db in + var lastError: Error? + var remainingMessagesToProcess: [Details.MessageInfo] = [] + + for messageInfo in details.messages { + do { + let info: MessageReceiver.InsertedInteractionInfo? = try MessageReceiver.handle( + db, + threadId: threadId, + threadVariant: messageInfo.threadVariant, + message: messageInfo.message, + decodedMessage: messageInfo.decodedMessage, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, + using: dependencies + ) + + /// Notify about the received message + MessageReceiver.prepareNotificationsForInsertedInteractions( + db, + insertedInteractionInfo: info, + isMessageRequest: dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest(threadId: threadId, threadVariant: messageInfo.threadVariant) + }, + using: dependencies + ) + } + catch { + // If the current message is a permanent failure then override it with the + // new error (we want to retry if there is a single non-permanent error) + switch error { + // Ignore duplicate and self-send errors (these will usually be caught during + // parsing but sometimes can get past and conflict at database insertion - eg. + // for open group messages) we also don't bother logging as it results in + // excessive logging which isn't useful) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE + MessageError.duplicateMessage, + MessageError.selfSend: + break + + case is MessageError: + Log.error(.cat, "Permanently failed message due to error: \(error)") + continue + + default: + Log.error(.cat, "Couldn't receive message due to error: \(error)") + lastError = error + + // We failed to process this message but it is a retryable error + // so add it to the list to re-process + remainingMessagesToProcess.append(messageInfo) + } } } + + /// If any messages failed to process then we want to update the job to only include those failed messages + guard !remainingMessagesToProcess.isEmpty else { return (job, lastError, []) } + + return ( + try job + .with(details: Details(messages: remainingMessagesToProcess)) + .defaulting(to: job) + .upserted(db), + lastError, + remainingMessagesToProcess + ) } - // If any messages failed to process then we want to update the job to only include - // those failed messages - guard !remainingMessagesToProcess.isEmpty else { return nil } - - updatedJob = try job - .with(details: Details(messages: remainingMessagesToProcess)) - .defaulting(to: job) - .upserted(db) - - return lastError - }, - completion: { result in - // Handle the result - switch result { - case .failure(let error): failure(updatedJob, error, false) - case .success(let lastError): - /// Report the result of the job - switch lastError { - case let error as MessageError: failure(updatedJob, error, true) - case .some(let error): failure(updatedJob, error, false) - case .none: success(updatedJob, false) - } - - success(updatedJob, false) + return scheduler.schedule { + /// Report the result of the job + switch result.lastError { + case let error as MessageError: failure(result.updatedJob, error, true) + case .some(let error): failure(result.updatedJob, error, false) + case .none: success(result.updatedJob, false) + } + + success(result.updatedJob, false) } } - ) + catch { + return scheduler.schedule { + failure(job, error, false) + } + } + } } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 90ed53626b..4faf1c58ca 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -75,8 +75,11 @@ internal extension LibSessionCacheType { ) }(), nicknameUpdate: .set(to: data.profile.nickname), + proUpdate: .none, // TODO: [PRO] Need to get this somehow? (sync via config? +// sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, profileUpdateTimestamp: data.profile.profileLastUpdated, cacheSource: .database, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index ade373d71f..40c6db9120 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -169,8 +169,10 @@ internal extension LibSessionCacheType { db, publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), - displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), + proUpdate: .none, // TODO: [PRO] Need to get this somehow? (sync via config? profileUpdateTimestamp: profile.profileLastUpdated, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index a2d2c6d06e..e93ee166c5 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -60,9 +60,12 @@ internal extension LibSessionCacheType { isReupload: false ) }(), + proUpdate: .none, // TODO: [PRO] Do we need to pass this +// sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, profileUpdateTimestamp: profileLastUpdateTimestamp, cacheSource: .value((oldState[.profile(userSessionId.hexString)] as? Profile), fallback: .database), suppressUserProfileConfigUpdate: true, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index e78cdfeb9b..67c73cc876 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -80,6 +80,13 @@ public actor CommunityManager: CommunityManagerType { _lastSuccessfulCommunityPollTimestamp = timestamp } + nonisolated public func currentUserSessionIdsSync(_ server: String) -> Set { + return ( + syncState.servers[server.lowercased()]?.currentUserSessionIds ?? + [syncState.dependencies[cache: .general].sessionId.hexString] + ) + } + public func fetchDefaultRoomsIfNeeded() async { /// If we don't have any default rooms in memory then we haven't fetched this launch so schedule /// the `RetrieveDefaultOpenGroupRoomsJob` if one isn't already running @@ -435,8 +442,8 @@ public actor CommunityManager: CommunityManagerType { try handlePollInfo( db, pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), - roomToken: roomToken, server: targetServer, + roomToken: roomToken, publicKey: publicKey, ) } @@ -577,8 +584,8 @@ public actor CommunityManager: CommunityManagerType { nonisolated public func handlePollInfo( _ db: ObservingDatabase, pollInfo: Network.SOGS.RoomPollInfo, - roomToken: String, server: String, + roomToken: String, publicKey: String ) throws { // Create the open group model and get or create the thread @@ -779,8 +786,9 @@ public actor CommunityManager: CommunityManagerType { nonisolated public func handleMessages( _ db: ObservingDatabase, messages: [Network.SOGS.Message], - for roomToken: String, - on server: String + server: String, + roomToken: String, + currentUserSessionIds: Set ) -> [MessageReceiver.InsertedInteractionInfo?] { guard let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) else { Log.error(.communityManager, "Couldn't handle open group messages due to missing group.") @@ -848,6 +856,7 @@ public actor CommunityManager: CommunityManagerType { decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, using: syncState.dependencies ) ) @@ -968,7 +977,8 @@ public actor CommunityManager: CommunityManagerType { _ db: ObservingDatabase, messages: [Network.SOGS.DirectMessage], fromOutbox: Bool, - on server: String + server: String, + currentUserSessionIds: Set ) -> [MessageReceiver.InsertedInteractionInfo?] { // Don't need to do anything if we have no messages (it's a valid case) guard !messages.isEmpty else { return [] } @@ -1104,6 +1114,7 @@ public actor CommunityManager: CommunityManagerType { decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, suppressNotifications: false, + currentUserSessionIds: currentUserSessionIds, using: syncState.dependencies ) ) @@ -1291,6 +1302,9 @@ public protocol CommunityManagerType { func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async + @available(*, deprecated, message: "use `server(_:)?.currentUserSessionIds` instead") + nonisolated func currentUserSessionIdsSync(_ server: String) -> Set + func fetchDefaultRoomsIfNeeded() async func loadCacheIfNeeded() async @@ -1344,21 +1358,23 @@ public protocol CommunityManagerType { nonisolated func handlePollInfo( _ db: ObservingDatabase, pollInfo: Network.SOGS.RoomPollInfo, - roomToken: String, server: String, + roomToken: String, publicKey: String ) throws nonisolated func handleMessages( _ db: ObservingDatabase, messages: [Network.SOGS.Message], - for roomToken: String, - on server: String + server: String, + roomToken: String, + currentUserSessionIds: Set ) -> [MessageReceiver.InsertedInteractionInfo?] nonisolated func handleDirectMessages( _ db: ObservingDatabase, messages: [Network.SOGS.DirectMessage], fromOutbox: Bool, - on server: String + server: String, + currentUserSessionIds: Set ) -> [MessageReceiver.InsertedInteractionInfo?] // MARK: - Convenience diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 27bbe21b87..859f999b48 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -15,6 +15,7 @@ extension MessageReceiver { decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { switch (message, try? SessionId(from: threadId)) { @@ -24,6 +25,7 @@ extension MessageReceiver { message: message, decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -33,6 +35,7 @@ extension MessageReceiver { message: message, decodedMessage: decodedMessage, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -82,6 +85,7 @@ extension MessageReceiver { groupSessionId: sessionId, message: message, decodedMessage: decodedMessage, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) return nil @@ -141,6 +145,7 @@ extension MessageReceiver { message: GroupUpdateInviteMessage, decodedMessage: DecodedMessage, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { // Ensure the message is valid @@ -152,10 +157,11 @@ extension MessageReceiver { db, publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - decodedPro: decodedMessage.decodedPro, + proUpdate: .contactUpdate(decodedMessage.decodedPro), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -239,6 +245,7 @@ extension MessageReceiver { message: GroupUpdatePromoteMessage, decodedMessage: DecodedMessage, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let groupIdentityKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate( @@ -252,10 +259,11 @@ extension MessageReceiver { db, publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - decodedPro: decodedMessage.decodedPro, + proUpdate: .contactUpdate(decodedMessage.decodedPro), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } @@ -594,6 +602,7 @@ extension MessageReceiver { groupSessionId: SessionId, message: GroupUpdateInviteResponseMessage, decodedMessage: DecodedMessage, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws { // Only process the invite response if it was an approval @@ -605,10 +614,11 @@ extension MessageReceiver { db, publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - decodedPro: decodedMessage.decodedPro, + proUpdate: .contactUpdate(decodedMessage.decodedPro), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index e254f5a557..ade3ae348e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -13,6 +13,7 @@ extension MessageReceiver { _ db: ObservingDatabase, message: MessageRequestResponse, decodedMessage: DecodedMessage, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { let userSessionId = dependencies[cache: .general].sessionId @@ -22,16 +23,16 @@ extension MessageReceiver { guard message.sender != userSessionId.hexString else { throw MessageError.ignorableMessage } guard let senderId: String = message.sender else { throw MessageError.missingRequiredField("sender") } - // Update profile if needed (want to do this regardless of whether the message exists or - // not to ensure the profile info gets sync between a users devices at every chance) + // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - decodedPro: decodedMessage.decodedPro, + displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), + proUpdate: .contactUpdate(decodedMessage.decodedPro), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 4a7cfa8a5a..2c5cba25d5 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -23,21 +23,22 @@ extension MessageReceiver { decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo { let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - // Update profile if needed (want to do this regardless of whether the message exists or - // not to ensure the profile info gets sync between a users devices at every chance) + // Update profile if needed if let profile = message.profile { try Profile.updateIfNeeded( db, publicKey: decodedMessage.sender.hexString, displayNameUpdate: .contactUpdate(profile.displayName), - displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), + displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - decodedPro: decodedMessage.decodedPro, + proUpdate: .contactUpdate(decodedMessage.decodedPro), profileUpdateTimestamp: profile.updateTimestampSeconds, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 39667825df..23d362b9e2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -186,6 +186,7 @@ public enum MessageReceiver { decodedMessage: DecodedMessage, serverExpirationTimestamp: TimeInterval?, suppressNotifications: Bool, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> InsertedInteractionInfo? { /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config @@ -241,6 +242,7 @@ public enum MessageReceiver { decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -291,6 +293,7 @@ public enum MessageReceiver { db, message: message, decodedMessage: decodedMessage, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) @@ -303,6 +306,7 @@ public enum MessageReceiver { decodedMessage: decodedMessage, serverExpirationTimestamp: serverExpirationTimestamp, suppressNotifications: suppressNotifications, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index 8a6a3b2254..e74e88e267 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -553,8 +553,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { try dependencies[singleton: .communityManager].handlePollInfo( db, pollInfo: responseBody, - roomToken: roomToken, server: pollerDestination.target, + roomToken: roomToken, publicKey: publicKey ) @@ -564,12 +564,16 @@ public final class CommunityPoller: CommunityPollerType & PollerType { let responseBody: [Failable] = responseData.body else { return } + /// Might have been updated when handling one of the other responses so re-fetch the value + let currentUserSessionIds: Set = dependencies[singleton: .communityManager] + .currentUserSessionIdsSync(pollerDestination.target.lowercased()) interactionInfo.append( contentsOf: dependencies[singleton: .communityManager].handleMessages( db, messages: responseBody.compactMap { $0.value }, - for: roomToken, - on: pollerDestination.target + server: pollerDestination.target, + roomToken: roomToken, + currentUserSessionIds: currentUserSessionIds ) ) @@ -588,12 +592,16 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } }() + /// Might have been updated when handling one of the other responses so re-fetch the value + let currentUserSessionIds: Set = dependencies[singleton: .communityManager] + .currentUserSessionIdsSync(pollerDestination.target.lowercased()) interactionInfo.append( contentsOf: dependencies[singleton: .communityManager].handleDirectMessages( db, messages: messages, fromOutbox: fromOutbox, - on: pollerDestination.target + server: pollerDestination.target, + currentUserSessionIds: currentUserSessionIds ) ) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index 432f479bd4..1ae823c7ae 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -297,6 +297,7 @@ public class SwarmPoller: SwarmPollerType & PollerType { } /// Since the hashes are still accurate we can now process the messages + let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId let allProcessedMessages: [ProcessedMessage] = sortedMessages .compactMap { namespace, messages, _ -> [ProcessedMessage]? in let processedMessages: [ProcessedMessage] = messages.compactMap { message -> ProcessedMessage? in @@ -386,7 +387,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { message: messageInfo.message, decodedMessage: messageInfo.decodedMessage, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - suppressNotifications: (source == .pushNotification), /// Have already shown + suppressNotifications: (source == .pushNotification), /// Have already shown + currentUserSessionIds: [currentUserSessionId.hexString], /// Swarm poller only has one using: dependencies ) diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift index f121b0f765..c54e983a9f 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift @@ -33,6 +33,7 @@ public extension SessionPro { // MARK: - CustomStringConvertible + // stringlint:ignore_contents public var description: String { var results: [String] = [] diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index a10e607464..64cc7a48e9 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -201,6 +201,7 @@ public extension MessageViewModel { linkPreviewCache: [String: [LinkPreview]], attachmentMap: [Int64: Set], isSenderModeratorOrAdmin: Bool, + userSessionId: SessionId, currentUserSessionIds: Set, previousInteraction: Interaction?, nextInteraction: Interaction?, @@ -216,17 +217,39 @@ public extension MessageViewModel { case (.none, .none): return nil } + let targetProfile: Profile = { + /// If the reactor is the current user then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(interaction.authorId) else { + return (profileCache[userSessionId.hexString] ?? Profile.defaultFor(userSessionId.hexString)) + } + + return (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) + }() let authorDisplayName: String = { guard !currentUserSessionIds.contains(interaction.authorId) else { return "you".localized() } return Profile.displayName( for: threadVariant, id: interaction.authorId, - name: profileCache[interaction.authorId]?.name, - nickname: profileCache[interaction.authorId]?.nickname, + name: targetProfile.name, + nickname: targetProfile.nickname, suppressId: false // Show the id next to the author name if desired ) }() + let threadContactDisplayName: String? = { + switch threadVariant { + case .contact: + return Profile.displayName( + for: threadVariant, + id: threadId, + name: profileCache[threadId]?.name, + nickname: profileCache[threadId]?.nickname, + suppressId: false // Show the id next to the author name if desired + ) + + default: return nil + } + }() let linkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = interaction.linkPreview( linkPreviewCache: linkPreviewCache, attachmentCache: attachmentCache @@ -237,10 +260,10 @@ public extension MessageViewModel { let body: String? = interaction.body( threadId: threadId, threadVariant: threadVariant, + threadContactDisplayName: threadContactDisplayName, authorDisplayName: authorDisplayName, attachments: attachments, linkPreview: linkPreviewInfo?.preview, - profileCache: profileCache, using: dependencies ) let proFeatures: SessionPro.Features = { @@ -273,7 +296,7 @@ public extension MessageViewModel { self.expiresInSeconds = interaction.expiresInSeconds self.attachments = attachments self.reactionInfo = (reactionInfo ?? []) - self.profile = (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) + self.profile = targetProfile self.quotedInfo = quotedInteraction.map { quotedInteraction -> QuotedInfo? in guard let quoteInteractionId: Int64 = quotedInteraction.id else { return nil } @@ -292,30 +315,29 @@ public extension MessageViewModel { .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) }() + let quotedAuthorDisplayName: String = { + guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { return "you".localized() } + + return Profile.displayName( + for: threadVariant, + id: interaction.authorId, + name: profileCache[quotedInteraction.authorId]?.name, + nickname: profileCache[quotedInteraction.authorId]?.nickname, + suppressId: false // Show the id next to the author name if desired + ) + }() return MessageViewModel.QuotedInfo( interactionId: quoteInteractionId, - authorName: { - guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { - return "you".localized() - } - - return Profile.displayName( - for: threadVariant, - id: quotedInteraction.authorId, - name: profileCache[quotedInteraction.authorId]?.name, - nickname: profileCache[quotedInteraction.authorId]?.nickname, - suppressId: true - ) - }(), + authorName: quotedAuthorDisplayName, timestampMs: quotedInteraction.timestampMs, body: quotedInteraction.body( threadId: threadId, threadVariant: threadVariant, - authorDisplayName: authorDisplayName, + threadContactDisplayName: threadContactDisplayName, + authorDisplayName: quotedAuthorDisplayName, attachments: quotedAttachments, linkPreview: quotedLinkPreviewInfo?.preview, - profileCache: profileCache, using: dependencies ), attachment: (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment), @@ -333,8 +355,8 @@ public extension MessageViewModel { return Profile.displayName( for: threadVariant, id: interaction.authorId, - name: profileCache[interaction.authorId]?.name, - nickname: profileCache[interaction.authorId]?.nickname, + name: targetProfile.name, + nickname: targetProfile.nickname, suppressId: true // Exclude the id next to the author name ) }() @@ -753,10 +775,10 @@ private extension Interaction { func body( threadId: String, threadVariant: SessionThread.Variant, + threadContactDisplayName: String?, authorDisplayName: String, attachments: [Attachment]?, linkPreview: LinkPreview?, - profileCache: [String: Profile], using dependencies: Dependencies ) -> String? { guard variant.isInfoMessage else { return body } @@ -765,13 +787,7 @@ private extension Interaction { return Interaction.previewText( variant: variant, body: body, - threadContactDisplayName: Profile.displayName( - for: threadVariant, - id: threadId, - name: profileCache[authorId]?.name, - nickname: profileCache[authorId]?.nickname, - suppressId: false // Show the id next to the author name if desired - ), + threadContactDisplayName: (threadContactDisplayName ?? ""), authorDisplayName: authorDisplayName, attachmentDescriptionInfo: attachments?.first.map { firstAttachment in Attachment.DescriptionInfo( diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 15d58531f0..a48e4fa85c 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -42,15 +42,15 @@ public class DisplayPictureManager { case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) case groupUpdateTo(url: String, key: Data) - static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback, using: dependencies) + static func contactUpdateTo(_ profile: VisibleMessage.VMProfile, fallback: Update) -> Update { + return contactUpdateTo(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback) } - public static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback, using: dependencies) + public static func contactUpdateTo(_ profile: Profile, fallback: Update) -> Update { + return contactUpdateTo(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback) } - static func from(_ url: String?, key: Data?, fallback: Update, using dependencies: Dependencies) -> Update { + static func contactUpdateTo(_ url: String?, key: Data?, fallback: Update) -> Update { guard let url: String = url, let key: Data = key diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 0003e24639..7b9b316f75 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -14,10 +14,10 @@ private extension Log.Category { // MARK: - Profile Updates public extension Profile { - enum DisplayNameUpdate { + enum TargetUserUpdate { case none - case contactUpdate(String?) - case currentUserUpdate(String?) + case contactUpdate(T) + case currentUserUpdate(T) } indirect enum CacheSource { @@ -88,8 +88,9 @@ public extension Profile { } static func updateLocal( - displayNameUpdate: DisplayNameUpdate = .none, + displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, + proUpdate: TargetUserUpdate = .none, using dependencies: Dependencies ) async throws { /// Perform any non-database related changes for the update @@ -131,8 +132,9 @@ public extension Profile { publicKey: userSessionId.hexString, displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, - decodedPro: dependencies[singleton: .sessionProManager].currentUserCurrentDecodedProForMessage, + proUpdate: proUpdate, profileUpdateTimestamp: profileUpdateTimestamp, + currentUserSessionIds: [userSessionId.hexString], using: dependencies ) } @@ -143,18 +145,18 @@ public extension Profile { static func updateIfNeeded( _ db: ObservingDatabase, publicKey: String, - displayNameUpdate: DisplayNameUpdate = .none, + displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, nicknameUpdate: Update = .useExisting, blocksCommunityMessageRequests: Update = .useExisting, - decodedPro: SessionPro.DecodedProForMessage?, + proUpdate: TargetUserUpdate = .none, profileUpdateTimestamp: TimeInterval?, cacheSource: CacheSource = .libSession(fallback: .database), suppressUserProfileConfigUpdate: Bool = false, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let isCurrentUser = (publicKey == userSessionId.hexString) + let isCurrentUser = currentUserSessionIds.contains(publicKey) let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) let updateStatus: UpdateStatus = UpdateStatus( updateTimestamp: profileUpdateTimestamp, @@ -246,74 +248,81 @@ public extension Profile { default: break } - /// Session Pro Information - let proInfo: SessionPro.DecodedProForMessage = (decodedPro ?? .nonPro) - - switch proInfo.status { - case .valid: - let originalChangeCount: Int = profileChanges.count - let finalFeatures: SessionPro.Features = proInfo.features.profileOnlyFeatures - - if profile.proFeatures != finalFeatures { - updatedProfile = updatedProfile.with(proFeatures: .set(to: finalFeatures)) - profileChanges.append(Profile.Columns.proFeatures.set(to: finalFeatures.rawValue)) - } - - if profile.proExpiryUnixTimestampMs != proInfo.proProof.expiryUnixTimestampMs { - let value: UInt64 = proInfo.proProof.expiryUnixTimestampMs - updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: value)) - profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: value)) - } - - if profile.proGenIndexHash != proInfo.proProof.genIndexHash.toHexString() { - let value: String = proInfo.proProof.genIndexHash.toHexString() - updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: value)) - profileChanges.append(Profile.Columns.proGenIndexHash.set(to: value)) - } - - /// If the change count no longer matches then the pro status was updated so we need to emit an event - if profileChanges.count != originalChangeCount { - db.addProfileEvent( - id: publicKey, - change: .proStatus( - isPro: true, - features: finalFeatures, - proExpiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, - proGenIndexHash: proInfo.proProof.genIndexHash.toHexString() - ) - ) - } - - default: - let originalChangeCount: Int = profileChanges.count - - if profile.proFeatures != .none { - updatedProfile = updatedProfile.with(proFeatures: .set(to: .none)) - profileChanges.append(Profile.Columns.proFeatures.set(to: .none)) - } - - if profile.proExpiryUnixTimestampMs > 0 { - updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: 0)) - profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: 0)) - } + /// Session Pro Information (if it's not the current user) + switch (proUpdate, isCurrentUser) { + case (.none, _): break + case (.contactUpdate(let value), false), (.currentUserUpdate(let value), true): + let proInfo: SessionPro.DecodedProForMessage = (value ?? .nonPro) - if profile.proGenIndexHash != nil { - updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: nil)) - profileChanges.append(Profile.Columns.proGenIndexHash.set(to: nil)) + switch proInfo.status { + case .valid: + let originalChangeCount: Int = profileChanges.count + let finalFeatures: SessionPro.Features = proInfo.features.profileOnlyFeatures + + if profile.proFeatures != finalFeatures { + updatedProfile = updatedProfile.with(proFeatures: .set(to: finalFeatures)) + profileChanges.append(Profile.Columns.proFeatures.set(to: finalFeatures.rawValue)) + } + + if profile.proExpiryUnixTimestampMs != proInfo.proProof.expiryUnixTimestampMs { + let value: UInt64 = proInfo.proProof.expiryUnixTimestampMs + updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: value)) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: value)) + } + + if profile.proGenIndexHash != proInfo.proProof.genIndexHash.toHexString() { + let value: String = proInfo.proProof.genIndexHash.toHexString() + updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: value)) + profileChanges.append(Profile.Columns.proGenIndexHash.set(to: value)) + } + + /// If the change count no longer matches then the pro status was updated so we need to emit an event + if profileChanges.count != originalChangeCount { + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: true, + features: finalFeatures, + proExpiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, + proGenIndexHash: proInfo.proProof.genIndexHash.toHexString() + ) + ) + } + + default: + let originalChangeCount: Int = profileChanges.count + + if profile.proFeatures != .none { + updatedProfile = updatedProfile.with(proFeatures: .set(to: .none)) + profileChanges.append(Profile.Columns.proFeatures.set(to: .none)) + } + + if profile.proExpiryUnixTimestampMs > 0 { + updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: 0)) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: 0)) + } + + if profile.proGenIndexHash != nil { + updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: nil)) + profileChanges.append(Profile.Columns.proGenIndexHash.set(to: nil)) + } + + /// If the change count no longer matches then the pro status was updated so we need to emit an event + if profileChanges.count != originalChangeCount { + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: false, + features: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHash: nil + ) + ) + } } - /// If the change count no longer matches then the pro status was updated so we need to emit an event - if profileChanges.count != originalChangeCount { - db.addProfileEvent( - id: publicKey, - change: .proStatus( - isPro: false, - features: .none, - proExpiryUnixTimestampMs: 0, - proGenIndexHash: nil - ) - ) - } + /// Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break } } @@ -429,6 +438,8 @@ public extension Profile { /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update if !suppressUserProfileConfigUpdate, isCurrentUser { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in // TODO: [PRO] Need to update the current users pro settings? diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 47bd9141d8..cd7637b1c7 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -3,6 +3,8 @@ import UIKit public class SessionProBadge: UIView { + public static let identifier: String = "ProBadge" // stringlint:ignore + public enum Size { case mini, small, medium, large From 9191307a7441a6a0995e5dab2437332353fb27c0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 14 Nov 2025 15:41:43 +1100 Subject: [PATCH 20/66] Updates for breaking API changes --- Session.xcodeproj/project.pbxproj | 32 +++++++++---------- .../DeveloperSettingsProViewModel.swift | 2 +- .../Crypto/Crypto+LibSession.swift | 24 +++++++------- ...roPaymentOrGenerateProProofResponse.swift} | 6 ++-- ...st.swift => GenerateProProofRequest.swift} | 12 +++---- ...quest.swift => GetProDetailsRequest.swift} | 12 +++---- ...onse.swift => GetProDetailsResponse.swift} | 20 ++++++------ .../SessionPro/SessionProAPI.swift | 32 +++++++++---------- 8 files changed, 70 insertions(+), 70 deletions(-) rename SessionNetworkingKit/SessionPro/Requests/{AddProPaymentOrGetProProofResponse.swift => AddProPaymentOrGenerateProProofResponse.swift} (85%) rename SessionNetworkingKit/SessionPro/Requests/{GetProProofRequest.swift => GenerateProProofRequest.swift} (73%) rename SessionNetworkingKit/SessionPro/Requests/{GetProStatusRequest.swift => GetProDetailsRequest.swift} (72%) rename SessionNetworkingKit/SessionPro/Requests/{GetProStatusResponse.swift => GetProDetailsResponse.swift} (79%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5e5f0a8856..b901046c94 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -464,7 +464,7 @@ FD0F856D2EA835C5004E0B98 /* Signatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856C2EA835B6004E0B98 /* Signatures.swift */; }; FD0F856F2EA83664004E0B98 /* UserTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F856E2EA83661004E0B98 /* UserTransaction.swift */; }; FD0F85732EA83C44004E0B98 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85722EA83C41004E0B98 /* AnyCodable.swift */; }; - FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGetProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */; }; + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */; }; FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85762EA83D8F004E0B98 /* ProProof.swift */; }; FD0F85792EA83EAD004E0B98 /* ResponseHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */; }; FD0F857B2EA85FAB004E0B98 /* Request+SessionProAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */; }; @@ -598,15 +598,15 @@ FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; - FD306BCC2EB02D9E00ADB003 /* GetProStatusRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */; }; + FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */; }; FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; - FD306BD02EB02F3900ADB003 /* GetProStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */; }; + FD306BD02EB02F3900ADB003 /* GetProDetailsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */; }; FD306BD22EB031AE00ADB003 /* PaymentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD12EB031AB00ADB003 /* PaymentItem.swift */; }; FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */; }; FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */; }; FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD72EB033CB00ADB003 /* Plan.swift */; }; FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */; }; - FD306BDC2EB0436C00ADB003 /* GetProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */; }; + FD306BDC2EB0436C00ADB003 /* GenerateProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */; }; FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; @@ -1899,7 +1899,7 @@ FD0F856C2EA835B6004E0B98 /* Signatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signatures.swift; sourceTree = ""; }; FD0F856E2EA83661004E0B98 /* UserTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTransaction.swift; sourceTree = ""; }; FD0F85722EA83C41004E0B98 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; - FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentOrGetProProofResponse.swift; sourceTree = ""; }; + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProPaymentOrGenerateProProofResponse.swift; sourceTree = ""; }; FD0F85762EA83D8F004E0B98 /* ProProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProProof.swift; sourceTree = ""; }; FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseHeader.swift; sourceTree = ""; }; FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Request+SessionProAPI.swift"; sourceTree = ""; }; @@ -2001,15 +2001,15 @@ FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; - FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProStatusRequest.swift; sourceTree = ""; }; + FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsRequest.swift; sourceTree = ""; }; FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; - FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProStatusResponse.swift; sourceTree = ""; }; + FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsResponse.swift; sourceTree = ""; }; FD306BD12EB031AB00ADB003 /* PaymentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentItem.swift; sourceTree = ""; }; FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatus.swift; sourceTree = ""; }; FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendUserProStatus.swift; sourceTree = ""; }; FD306BD72EB033CB00ADB003 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderMetadata.swift; sourceTree = ""; }; - FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProProofRequest.swift; sourceTree = ""; }; + FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateProProofRequest.swift; sourceTree = ""; }; FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPollerCache.swift; sourceTree = ""; }; @@ -4163,10 +4163,10 @@ isa = PBXGroup; children = ( FD0F856A2EA8351E004E0B98 /* AppProPaymentRequest.swift */, - FD0F85742EA83D49004E0B98 /* AddProPaymentOrGetProProofResponse.swift */, - FD306BDB2EB0436800ADB003 /* GetProProofRequest.swift */, - FD306BCB2EB02D9B00ADB003 /* GetProStatusRequest.swift */, - FD306BCF2EB02F3500ADB003 /* GetProStatusResponse.swift */, + FD0F85742EA83D49004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift */, + FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */, + FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */, + FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */, ); path = Requests; sourceTree = ""; @@ -6558,13 +6558,13 @@ buildActionMask = 2147483647; files = ( FD6B92E12E77C1E1004463B5 /* PushNotification.swift in Sources */, - FD306BD02EB02F3900ADB003 /* GetProStatusResponse.swift in Sources */, + FD306BD02EB02F3900ADB003 /* GetProDetailsResponse.swift in Sources */, FD2272B12C33E337004D8A6C /* ProxiedContentDownloader.swift in Sources */, FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, FD6B928E2E779E99004463B5 /* FileServerEndpoint.swift in Sources */, FDF8489129405C13007DCAE5 /* SnodeAPINamespace.swift in Sources */, FD2272AB2C33E337004D8A6C /* ValidatableResponse.swift in Sources */, - FD306BCC2EB02D9E00ADB003 /* GetProStatusRequest.swift in Sources */, + FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */, FD2272B72C33E337004D8A6C /* Request.swift in Sources */, FD2272B92C33E337004D8A6C /* ResponseInfo.swift in Sources */, FD6B92F82E77C725004463B5 /* ProcessResult.swift in Sources */, @@ -6632,7 +6632,7 @@ FD6B92F22E77C5D1004463B5 /* UnsubscribeResponse.swift in Sources */, 947D7FD62D509FC900E8E413 /* SessionNetworkAPI.swift in Sources */, 947D7FD72D509FC900E8E413 /* HTTPClient.swift in Sources */, - FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGetProProofResponse.swift in Sources */, + FD0F85752EA83D5D004E0B98 /* AddProPaymentOrGenerateProProofResponse.swift in Sources */, FD6B92B42E77AA11004463B5 /* PinnedMessage.swift in Sources */, FD6B92B52E77AA11004463B5 /* SendDirectMessageResponse.swift in Sources */, FD6B92B62E77AA11004463B5 /* UserUnbanRequest.swift in Sources */, @@ -6647,7 +6647,7 @@ FD6B92BF2E77AA11004463B5 /* DeleteInboxResponse.swift in Sources */, FD6B92C02E77AA11004463B5 /* SendDirectMessageRequest.swift in Sources */, FD6B92C12E77AA11004463B5 /* CapabilitiesResponse.swift in Sources */, - FD306BDC2EB0436C00ADB003 /* GetProProofRequest.swift in Sources */, + FD306BDC2EB0436C00ADB003 /* GenerateProProofRequest.swift in Sources */, FD6B92C22E77AA11004463B5 /* SOGSMessage.swift in Sources */, 947D7FD82D509FC900E8E413 /* KeyValueStore+SessionNetwork.swift in Sources */, FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index c3e5d9e85e..d43d16238a 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -843,7 +843,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold guard let masterKeyPair: KeyPair = await internalState.submittedTransactionMasterKeyPair else { return } do { - let request = try? Network.SessionPro.getProStatus( + let request = try? Network.SessionPro.getProDetails( masterKeyPair: masterKeyPair, using: dependencies ) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 06c42a2727..063b5cd0a6 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -18,9 +18,9 @@ public extension Crypto.Generator { args: [] ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - let cRotatingProPubkey: [UInt8]? = dependencies[singleton: .sessionProManager] + let cRotatingProSecretKey: [UInt8]? = dependencies[singleton: .sessionProManager] .currentUserCurrentRotatingKeyPair? - .publicKey + .secretKey guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } @@ -39,8 +39,8 @@ public extension Crypto.Generator { cEd25519SecretKey.count, sentTimestampMs, &cPubkey, - cRotatingProPubkey, - (cRotatingProPubkey?.count ?? 0), + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), &error, error.count ) @@ -55,8 +55,8 @@ public extension Crypto.Generator { cEd25519SecretKey.count, sentTimestampMs, &cPubkey, - cRotatingProPubkey, - (cRotatingProPubkey?.count ?? 0), + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), &error, error.count ) @@ -78,8 +78,8 @@ public extension Crypto.Generator { sentTimestampMs, &cPubkey, &cCurrentGroupEncPrivateKey, - cRotatingProPubkey, - (cRotatingProPubkey?.count ?? 0), + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), &error, error.count ) @@ -88,8 +88,8 @@ public extension Crypto.Generator { result = session_protocol_encode_for_community( cPlaintext, cPlaintext.count, - cRotatingProPubkey, - (cRotatingProPubkey?.count ?? 0), + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), &error, error.count ) @@ -107,8 +107,8 @@ public extension Crypto.Generator { sentTimestampMs, &cRecipientPubkey, &cServerPubkey, - cRotatingProPubkey, - (cRotatingProPubkey?.count ?? 0), + cRotatingProSecretKey, + (cRotatingProSecretKey?.count ?? 0), &error, error.count ) diff --git a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift similarity index 85% rename from SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift rename to SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift index 1e3609ff58..f8a3745ef7 100644 --- a/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGetProProofResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/AddProPaymentOrGenerateProProofResponse.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct AddProPaymentOrGetProProofResponse: Decodable, Equatable { + struct AddProPaymentOrGenerateProProofResponse: Decodable, Equatable { public let header: ResponseHeader public let proof: ProProof @@ -32,12 +32,12 @@ public extension Network.SessionPro { } var result = jsonData.withUnsafeBytes { bytes in - session_pro_backend_add_pro_payment_or_get_pro_proof_response_parse( + session_pro_backend_add_pro_payment_or_generate_pro_proof_response_parse( bytes.baseAddress?.assumingMemoryBound(to: CChar.self), jsonData.count ) } - defer { session_pro_backend_add_pro_payment_or_get_pro_proof_response_free(&result) } + defer { session_pro_backend_add_pro_payment_or_generate_pro_proof_response_free(&result) } self.header = ResponseHeader(result.header) self.proof = ProProof(result.proof) diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift similarity index 73% rename from SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift rename to SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift index b8916adf8a..65e377ae28 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProProofRequest.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GenerateProProofRequest.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct GetProProofRequest: Encodable, Equatable { + struct GenerateProProofRequest: Encodable, Equatable { public let masterPublicKey: [UInt8] public let rotatingPublicKey: [UInt8] public let timestampMs: UInt64 @@ -13,8 +13,8 @@ public extension Network.SessionPro { // MARK: - Functions - func toLibSession() -> session_pro_backend_get_pro_proof_request { - var result: session_pro_backend_get_pro_proof_request = session_pro_backend_get_pro_proof_request() + func toLibSession() -> session_pro_backend_generate_pro_proof_request { + var result: session_pro_backend_generate_pro_proof_request = session_pro_backend_generate_pro_proof_request() result.version = Network.SessionPro.apiVersion result.set(\.master_pkey, to: masterPublicKey) result.set(\.rotating_pkey, to: rotatingPublicKey) @@ -26,8 +26,8 @@ public extension Network.SessionPro { } public func encode(to encoder: any Encoder) throws { - var cRequest: session_pro_backend_get_pro_proof_request = toLibSession() - var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_proof_request_to_json(&cRequest); + var cRequest: session_pro_backend_generate_pro_proof_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_generate_pro_proof_request_to_json(&cRequest); defer { session_pro_backend_to_json_free(&cJson) } guard cJson.success else { throw NetworkError.invalidPayload } @@ -39,4 +39,4 @@ public extension Network.SessionPro { } } -extension session_pro_backend_get_pro_proof_request: @retroactive CMutable {} +extension session_pro_backend_generate_pro_proof_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift similarity index 72% rename from SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift rename to SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift index 7d2cd78e2f..7ca795c0b4 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProStatusRequest.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsRequest.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct GetProStatusRequest: Encodable, Equatable { + struct GetProDetailsRequest: Encodable, Equatable { public let masterPublicKey: [UInt8] public let timestampMs: UInt64 public let count: UInt32 @@ -13,8 +13,8 @@ public extension Network.SessionPro { // MARK: - Functions - func toLibSession() -> session_pro_backend_get_pro_status_request { - var result: session_pro_backend_get_pro_status_request = session_pro_backend_get_pro_status_request() + func toLibSession() -> session_pro_backend_get_pro_details_request { + var result: session_pro_backend_get_pro_details_request = session_pro_backend_get_pro_details_request() result.version = Network.SessionPro.apiVersion result.set(\.master_pkey, to: masterPublicKey) result.set(\.master_sig, to: signature.signature) @@ -25,8 +25,8 @@ public extension Network.SessionPro { } public func encode(to encoder: any Encoder) throws { - var cRequest: session_pro_backend_get_pro_status_request = toLibSession() - var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_status_request_to_json(&cRequest); + var cRequest: session_pro_backend_get_pro_details_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_details_request_to_json(&cRequest); defer { session_pro_backend_to_json_free(&cJson) } guard cJson.success else { throw NetworkError.invalidPayload } @@ -38,4 +38,4 @@ public extension Network.SessionPro { } } -extension session_pro_backend_get_pro_status_request: @retroactive CMutable {} +extension session_pro_backend_get_pro_details_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift similarity index 79% rename from SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift rename to SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift index 2b87370a31..3863d0ba5f 100644 --- a/SessionNetworkingKit/SessionPro/Requests/GetProStatusResponse.swift +++ b/SessionNetworkingKit/SessionPro/Requests/GetProDetailsResponse.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct GetProStatusResponse: Decodable, Equatable { + struct GetProDetailsResponse: Decodable, Equatable { public let header: ResponseHeader public let items: [PaymentItem] public let status: BackendUserProStatus @@ -38,12 +38,12 @@ public extension Network.SessionPro { } var result = jsonData.withUnsafeBytes { bytes in - session_pro_backend_get_pro_status_response_parse( + session_pro_backend_get_pro_details_response_parse( bytes.baseAddress?.assumingMemoryBound(to: CChar.self), jsonData.count ) } - defer { session_pro_backend_get_pro_status_response_free(&result) } + defer { session_pro_backend_get_pro_details_response_free(&result) } self.header = ResponseHeader(result.header) self.status = BackendUserProStatus(result.status) @@ -65,22 +65,22 @@ public extension Network.SessionPro { } } -public extension Network.SessionPro.GetProStatusResponse { +public extension Network.SessionPro.GetProDetailsResponse { enum ErrorReport: CaseIterable { case success case genericError - var libSessionValue: SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT { + var libSessionValue: SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT { switch self { - case .success: return SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT_SUCCESS - case .genericError: return SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT_GENERIC_ERROR + case .success: return SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT_SUCCESS + case .genericError: return SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT_GENERIC_ERROR } } - init(_ libSessionValue: SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT) { + init(_ libSessionValue: SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT) { switch libSessionValue { - case SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT_SUCCESS: self = .success - case SESSION_PRO_BACKEND_GET_PRO_STATUS_ERROR_REPORT_GENERIC_ERROR: self = .genericError + case SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT_SUCCESS: self = .success + case SESSION_PRO_BACKEND_GET_PRO_DETAILS_ERROR_REPORT_GENERIC_ERROR: self = .genericError default: self = .genericError } } diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 70deaa8b5e..89b2bbdb84 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -42,11 +42,11 @@ public extension Network.SessionPro { .values .first(where: { _ in true })?.1 - let proStatusRequest = try? Network.SessionPro.getProStatus( + let proDetailsRequest = try? Network.SessionPro.getProDetails( masterKeyPair: masterKeyPair, using: dependencies ) - let proStatusResponse = try await proStatusRequest + let proDetailsResponse = try await proDetailsRequest .send(using: dependencies) .values .first(where: { _ in true })?.1 @@ -54,7 +54,7 @@ public extension Network.SessionPro { await MainActor.run { let tmp1 = addProProofResponse let tmp2 = proProofResponse - let tmp3 = proStatusResponse + let tmp3 = proDetailsResponse print("RAWR Test Success") } } @@ -69,7 +69,7 @@ public extension Network.SessionPro { masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey let cTransactionId: [UInt8] = Array(transactionId.utf8) @@ -104,7 +104,7 @@ public extension Network.SessionPro { ), using: dependencies ), - responseType: AddProPaymentOrGetProProofResponse.self, + responseType: AddProPaymentOrGenerateProProofResponse.self, using: dependencies ) } @@ -113,12 +113,12 @@ public extension Network.SessionPro { masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey let cRotatingPrivateKey: [UInt8] = rotatingKeyPair.secretKey let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let signatures: Signatures = try Signatures( - session_pro_backend_get_pro_proof_request_build_sigs( + session_pro_backend_generate_pro_proof_request_build_sigs( Network.SessionPro.apiVersion, cMasterPrivateKey, cMasterPrivateKey.count, @@ -129,10 +129,10 @@ public extension Network.SessionPro { ) return try Network.PreparedRequest( - request: try Request( + request: try Request( method: .post, endpoint: .getProProof, - body: GetProProofRequest( + body: GenerateProProofRequest( masterPublicKey: masterKeyPair.publicKey, rotatingPublicKey: rotatingKeyPair.publicKey, timestampMs: timestampMs, @@ -140,20 +140,20 @@ public extension Network.SessionPro { ), using: dependencies ), - responseType: AddProPaymentOrGetProProofResponse.self, + responseType: AddProPaymentOrGenerateProProofResponse.self, using: dependencies ) } - static func getProStatus( + static func getProDetails( count: UInt32 = 1, masterKeyPair: KeyPair, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let signature: Signature = try Signature( - session_pro_backend_get_pro_status_request_build_sig( + session_pro_backend_get_pro_details_request_build_sig( Network.SessionPro.apiVersion, cMasterPrivateKey, cMasterPrivateKey.count, @@ -163,10 +163,10 @@ public extension Network.SessionPro { ) return try Network.PreparedRequest( - request: try Request( + request: try Request( method: .post, endpoint: .getProStatus, - body: GetProStatusRequest( + body: GetProDetailsRequest( masterPublicKey: masterKeyPair.publicKey, timestampMs: timestampMs, count: count, @@ -174,7 +174,7 @@ public extension Network.SessionPro { ), using: dependencies ), - responseType: GetProStatusResponse.self, + responseType: GetProDetailsResponse.self, using: dependencies ) } From 270f1f33dfdfb799614dd091fc27a20c66eb02b1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 19 Nov 2025 08:54:37 +1100 Subject: [PATCH 21/66] Added a bunch of Pro handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added a dev setting to fake an Apple subscription for testing Pro against dev • Added pro content to protobufs and handle receiving pro content from protobufs • Updated code for latest pro API changes • Updated the logic to only add pro data if pro features are actually used • Wired up the pro state config syncing for both the current user and contacts • Reworked some config handling logic so all merges happen before processing the merge changes (this way we can handle multi-config changes properly) --- Session.xcodeproj/project.pbxproj | 4 + .../Conversations/ConversationViewModel.swift | 4 +- Session/Home/HomeViewModel.swift | 4 +- Session/Onboarding/Onboarding.swift | 2 +- .../DeveloperSettingsProViewModel.swift | 154 ++++----- .../Crypto/Crypto+LibSession.swift | 16 +- .../_028_GenerateInitialUserConfigDumps.swift | 1 + .../Migrations/_048_SessionProChanges.swift | 2 +- .../Database/Models/Profile.swift | 16 +- .../Config Handling/LibSession+Contacts.swift | 55 +++- .../LibSession+ConvoInfoVolatile.swift | 104 +++++- .../LibSession+GroupInfo.swift | 5 +- .../LibSession+GroupMembers.swift | 2 +- .../Config Handling/LibSession+Shared.swift | 32 +- .../LibSession+UserProfile.swift | 85 +++-- .../LibSession+SessionMessagingKit.swift | 302 +++++++++--------- SessionMessagingKit/Messages/Message.swift | 8 +- .../VisibleMessage+Profile.swift | 13 +- .../Visible Messages/VisibleMessage.swift | 61 +++- .../Protos/Generated/SNProto.swift | 302 +++++++++++++++++- .../Protos/Generated/SessionProtos.pb.swift | 289 +++++++++++++++++ .../Protos/SessionProtos.proto | 72 +++++ .../MessageReceiver+VisibleMessages.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 7 + .../Sending & Receiving/MessageSender.swift | 8 +- .../SessionPro/SessionProManager.swift | 274 ++++++++++++++-- .../SessionPro/Types/SessionProConfig.swift | 38 +++ .../ObservableKey+SessionMessagingKit.swift | 2 +- .../Utilities/Profile+Updating.swift | 18 +- .../_TestUtilities/MockLibSessionCache.swift | 33 +- .../SessionPro/SessionProAPI.swift | 15 +- .../SessionPro/SessionProEndpoint.swift | 8 +- .../Types/PaymentProviderMetadata.swift | 12 +- SessionNetworkingKit/Types/NetworkError.swift | 2 + .../NotificationServiceExtension.swift | 30 +- SessionTests/Onboarding/OnboardingSpec.swift | 1 + SessionUtilitiesKit/General/Feature.swift | 4 + 37 files changed, 1593 insertions(+), 394 deletions(-) create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProConfig.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 649e5440a0..cda216c616 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -646,6 +646,7 @@ FD360EB92ECAB1470050CAF4 /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EB82ECAB1470050CAF4 /* Punycode */; }; FD360EBB2ECAB1500050CAF4 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBA2ECAB1500050CAF4 /* SwiftProtobuf */; }; FD360EBD2ECAB15A0050CAF4 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBC2ECAB15A0050CAF4 /* Lucide */; }; + FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; @@ -2042,6 +2043,7 @@ FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityPollerSpec.swift; sourceTree = ""; }; FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPoller.swift; sourceTree = ""; }; FD3559452CC1FF140088F2A9 /* _034_AddMissingWhisperFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _034_AddMissingWhisperFlag.swift; sourceTree = ""; }; + FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSnodeAPICache.swift; sourceTree = ""; }; @@ -5126,6 +5128,7 @@ FDAA36C42EB474B50040603E /* Types */ = { isa = PBXGroup; children = ( + FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FDAA36C92EB476060040603E /* SessionProFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, @@ -6972,6 +6975,7 @@ FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */, 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */, FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, + FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 8a93997cc6..32b4f735ac 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -648,7 +648,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .name(let name): profileData = profileData.with(name: name) case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) - case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHash): + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): let finalFeatures: SessionPro.Features = { guard dependencies[feature: .sessionProEnabled] else { return .none } @@ -659,7 +659,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold profileData = profileData.with( proFeatures: .set(to: finalFeatures), proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), - proGenIndexHash: .set(to: proGenIndexHash) + proGenIndexHashHex: .set(to: proGenIndexHashHex) ) } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 63181a4469..dfc7287aee 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -297,7 +297,7 @@ public class HomeViewModel: NavigatableStateHolder { case .name(let name): userProfile = userProfile.with(name: name) case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) - case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHash): + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): let finalFeatures: SessionPro.Features = { guard dependencies[feature: .sessionProEnabled] else { return .none } @@ -308,7 +308,7 @@ public class HomeViewModel: NavigatableStateHolder { userProfile = userProfile.with( proFeatures: .set(to: finalFeatures), proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), - proGenIndexHash: .set(to: proGenIndexHash) + proGenIndexHashHex: .set(to: proGenIndexHashHex) ) } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 30cbe16103..570303a63f 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -276,7 +276,7 @@ extension Onboarding { userEd25519SecretKey: identity.ed25519KeyPair.secretKey, groupEd25519SecretKey: nil ) - try cache.unsafeDirectMergeConfigMessage( + _ = try cache.mergeConfigMessages( swarmPublicKey: userSessionId.hexString, messages: [ ConfigMessageReceiveJob.Details.MessageInfo( diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index d43d16238a..d731316446 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -72,6 +72,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case proStatus case proBadgeEverywhere + case fakeAppleSubscriptionForDev case forceMessageFeatureProBadge case forceMessageFeatureLongMessage @@ -83,6 +84,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case requestRefund case submitPurchaseToProBackend case refreshProStatus + case removeProFromUserConfig // MARK: - Conformance @@ -94,6 +96,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .proStatus: return "proStatus" case .proBadgeEverywhere: return "proBadgeEverywhere" + case .fakeAppleSubscriptionForDev: return "fakeAppleSubscriptionForDev" case .forceMessageFeatureProBadge: return "forceMessageFeatureProBadge" case .forceMessageFeatureLongMessage: return "forceMessageFeatureLongMessage" @@ -105,6 +108,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .requestRefund: return "requestRefund" case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" case .refreshProStatus: return "refreshProStatus" + case .removeProFromUserConfig: return "removeProFromUserConfig" } } @@ -119,6 +123,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .proStatus: result.append(.proStatus); fallthrough case .proBadgeEverywhere: result.append(.proBadgeEverywhere); fallthrough + case .fakeAppleSubscriptionForDev: result.append(.fakeAppleSubscriptionForDev); fallthrough case .forceMessageFeatureProBadge: result.append(.forceMessageFeatureProBadge); fallthrough case .forceMessageFeatureLongMessage: result.append(.forceMessageFeatureLongMessage); fallthrough @@ -129,7 +134,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough - case .refreshProStatus: result.append(.refreshProStatus) + case .refreshProStatus: result.append(.refreshProStatus); fallthrough + case .removeProFromUserConfig: result.append(.removeProFromUserConfig) } return result @@ -139,7 +145,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum DeveloperSettingsProEvent: Hashable { case purchasedProduct([Product], Product?, String?, String?, Transaction?) case refundTransaction(Transaction.RefundRequestStatus) - case submittedTranasction(KeyPair?, KeyPair?, String?, Bool) + case submittedTransaction(String?, Bool) case currentProStatus(String?, Bool) } @@ -150,6 +156,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let mockCurrentUserSessionProBackendStatus: Network.SessionPro.BackendUserProStatus? let proBadgeEverywhere: Bool + let fakeAppleSubscriptionForDev: Bool let forceMessageFeatureProBadge: Bool let forceMessageFeatureLongMessage: Bool @@ -162,8 +169,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchaseTransaction: Transaction? let refundRequestStatus: Transaction.RefundRequestStatus? - let submittedTransactionMasterKeyPair: KeyPair? - let submittedTransactionRotatingKeyPair: KeyPair? let submittedTransactionStatus: String? let submittedTransactionErrored: Bool @@ -182,6 +187,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .feature(.mockCurrentUserSessionProBackendStatus), .feature(.proBadgeEverywhere), + .feature(.fakeAppleSubscriptionForDev), .feature(.forceMessageFeatureProBadge), .feature(.forceMessageFeatureLongMessage), .feature(.forceMessageFeatureAnimatedAvatar), @@ -194,6 +200,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], + fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], @@ -206,8 +213,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransaction: nil, refundRequestStatus: nil, - submittedTransactionMasterKeyPair: nil, - submittedTransactionRotatingKeyPair: nil, submittedTransactionStatus: nil, submittedTransactionErrored: false, @@ -234,8 +239,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var purchaseStatus: String? = previousState.purchaseStatus var purchaseTransaction: Transaction? = previousState.purchaseTransaction var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus - var submittedTransactionMasterKeyPair: KeyPair? = previousState.submittedTransactionMasterKeyPair - var submittedTransactionRotatingKeyPair: KeyPair? = previousState.submittedTransactionRotatingKeyPair var submittedTransactionStatus: String? = previousState.submittedTransactionStatus var submittedTransactionErrored: Bool = previousState.submittedTransactionErrored @@ -253,9 +256,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .refundTransaction(let status): refundRequestStatus = status - case .submittedTranasction(let masterKeyPair, let rotatingKeyPair, let status, let errored): - submittedTransactionMasterKeyPair = masterKeyPair - submittedTransactionRotatingKeyPair = rotatingKeyPair + case .submittedTransaction(let status, let errored): submittedTransactionStatus = status submittedTransactionErrored = errored @@ -269,6 +270,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], + fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], @@ -278,8 +280,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseStatus: purchaseStatus, purchaseTransaction: purchaseTransaction, refundRequestStatus: refundRequestStatus, - submittedTransactionMasterKeyPair: submittedTransactionMasterKeyPair, - submittedTransactionRotatingKeyPair: submittedTransactionRotatingKeyPair, submittedTransactionStatus: submittedTransactionStatus, submittedTransactionErrored: submittedTransactionErrored, currentProStatus: currentProStatus, @@ -359,6 +359,25 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } ), + SessionCell.Info( + id: .fakeAppleSubscriptionForDev, + title: "Fake the Apple Subscription for Pro Purchases", + subtitle: """ + Apple subscriptions (even with Sandbox accounts) can't be tested on the iOS Simulator, to work around this the dev pro server allows "fake" transaction identifiers for the purposes of testing. + + This setting will bypass the AppStore section of the purchase flow and generate a fake transaction identifier to send to the Pro backend to create the purchase. + """, + trailingAccessory: .toggle( + state.fakeAppleSubscriptionForDev, + oldValue: previousState.fakeAppleSubscriptionForDev + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .fakeAppleSubscriptionForDev, + to: !state.fakeAppleSubscriptionForDev + ) + } + ), SessionCell.Info( id: .forceMessageFeatureProBadge, title: "Message Feature: Pro Badge", @@ -432,10 +451,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold @unknown default: return "N/A" } }() - let rotatingPubkey: String = ( - (state.submittedTransactionRotatingKeyPair?.publicKey).map { "\($0.toHexString())" } ?? - "N/A" - ) let submittedTransactionStatus: String = { switch (state.submittedTransactionStatus, state.submittedTransactionErrored) { case (.some(let error), true): return "\(error)" @@ -503,7 +518,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Status: \(refundStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Request"), - isEnabled: (state.purchaseTransaction != nil), + isEnabled: ( + state.purchaseTransaction != nil || + state.fakeAppleSubscriptionForDev + ), onTap: { [weak viewModel] in Task { await viewModel?.requestRefund() } } @@ -514,11 +532,13 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Submit a purchase to the Session Pro Backend. - Rotating Pubkey: \(rotatingPubkey) Status: \(submittedTransactionStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Submit"), - isEnabled: (state.purchaseTransaction != nil), + isEnabled: ( + state.purchaseTransaction != nil || + state.fakeAppleSubscriptionForDev + ), onTap: { [weak viewModel] in Task { await viewModel?.submitTransactionToProBackend() } } @@ -532,10 +552,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Status: \(currentProStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Refresh"), - isEnabled: (state.submittedTransactionMasterKeyPair != nil), onTap: { [weak viewModel] in Task { await viewModel?.refreshProStatus() } } + ), + SessionCell.Info( + id: .removeProFromUserConfig, + title: "Remove Pro From User Config", + subtitle: """ + Remove the cached pro state from the configs (this will mean the local device doesn't know that the user has pro on restart). + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Remove"), + onTap: { [weak viewModel] in + Task { await viewModel?.removeProFromUserConfig() } + } ) ] ) @@ -794,80 +824,44 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } private func submitTransactionToProBackend() async { - guard let transaction: Transaction = await internalState.purchaseTransaction else { return } - do { - let masterKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - let rotatingKeyPair: KeyPair = try dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - let request = try? Network.SessionPro.addProPaymentOrGetProProof( - transactionId: "\(transaction.id)", - masterKeyPair: masterKeyPair, - rotatingKeyPair: rotatingKeyPair, - using: dependencies - ) - // FIXME: Make this async/await when the refactored networking is merged - let response = try await request - .send(using: dependencies) - .values - .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + let transactionId: String = try await { + guard await internalState.fakeAppleSubscriptionForDev else { + guard let transaction: Transaction = await internalState.purchaseTransaction else { + throw NetworkError.explicit("No Transaction") + } + + return "\(transaction.id)" + } + + let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) + return "DEV.\(bytes.toHexString())" + }() - guard response.header.errors.isEmpty else { - Log.error("[DevSettings] Tranasction submission failed: \(response.header.errors[0])") - dependencies.notifyAsync( - key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.submittedTranasction( - masterKeyPair, - rotatingKeyPair, - "Failed: \(response.header.errors[0])", - true - ) - ) - return - } + try await dependencies[singleton: .sessionProManager].addProPayment(transactionId: transactionId) dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.submittedTranasction(masterKeyPair, rotatingKeyPair, "Success", false) + value: DeveloperSettingsProEvent.submittedTransaction("Success", false) ) } catch { Log.error("[DevSettings] Tranasction submission failed: \(error)") dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.submittedTranasction(nil, nil, "Failed: \(error)", true) + value: DeveloperSettingsProEvent.submittedTransaction("Failed: \(error)", true) ) } } private func refreshProStatus() async { - guard let masterKeyPair: KeyPair = await internalState.submittedTransactionMasterKeyPair else { return } - do { - let request = try? Network.SessionPro.getProDetails( - masterKeyPair: masterKeyPair, - using: dependencies - ) - // FIXME: Make this async/await when the refactored networking is merged - let response = try await request - .send(using: dependencies) - .values - .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() - - guard response.header.errors.isEmpty else { - Log.error("[DevSettings] Refresh pro status failed: \(response.header.errors[0])") - dependencies.notifyAsync( - key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.currentProStatus( - "Error: \(response.header.errors[0])", - true - ) - ) - return - } + try await dependencies[singleton: .sessionProManager].refreshStatus() + let status: Network.SessionPro.BackendUserProStatus? = dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.currentProStatus("\(response.status)", false) + value: DeveloperSettingsProEvent.currentProStatus("\(status.map { "\($0)" } ?? "Unknown")", false) ) } catch { @@ -878,6 +872,16 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } } + + private func removeProFromUserConfig() async { + try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.removeProConfig() + } + } + } + } } extension Product: @retroactive Comparable { diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 063b5cd0a6..c08be96995 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -10,6 +10,7 @@ import SessionUtilitiesKit public extension Crypto.Generator { static func encodedMessage( plaintext: I, + proFeatures: SessionPro.Features, destination: Message.Destination, sentTimestampMs: UInt64 ) throws -> Crypto.Generator where R.Element == UInt8 { @@ -18,9 +19,14 @@ public extension Crypto.Generator { args: [] ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - let cRotatingProSecretKey: [UInt8]? = dependencies[singleton: .sessionProManager] - .currentUserCurrentRotatingKeyPair? - .secretKey + let cRotatingProSecretKey: [UInt8]? = { + /// If the message doens't contain any pro features then we shouldn't include a pro signature + guard proFeatures != .none else { return nil } + + return dependencies[singleton: .sessionProManager] + .currentUserCurrentRotatingKeyPair? + .secretKey + }() guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } @@ -393,11 +399,11 @@ public extension Crypto.Verification { public extension Crypto.Generator { static func sessionProMasterKeyPair() -> Crypto.Generator { return Crypto.Generator( - id: "encodedMessage", + id: "sessionProMasterKeyPair", args: [] ) { dependencies in let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey - var cMasterSecretKey: [UInt8] = [UInt8](repeating: 0, count: 256) + var cMasterSecretKey: [UInt8] = [UInt8](repeating: 0, count: 64) guard !cEd25519SecretKey.isEmpty else { throw CryptoError.missingUserSecretKey } diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index fc0e97b3d2..ee54acb3db 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -64,6 +64,7 @@ enum _028_GenerateInitialUserConfigDumps: Migration { displayName: .set(to: (userProfile?["name"] ?? "")), displayPictureUrl: .set(to: userProfile?["profilePictureUrl"]), displayPictureEncryptionKey: .set(to: userProfile?["profileEncryptionKey"]), + proFeatures: .useExisting, isReuploadProfilePicture: false ) diff --git a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift index 1442d3696d..e1cef71480 100644 --- a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift @@ -18,7 +18,7 @@ enum _048_SessionProChanges: Migration { try db.alter(table: "profile") { t in t.add(column: "proFeatures", .integer).defaults(to: 0) t.add(column: "proExpiryUnixTimestampMs", .integer).defaults(to: 0) - t.add(column: "proGenIndexHash", .text) + t.add(column: "proGenIndexHashHex", .text) } MigrationExecution.updateProgress(1) diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 5fdabc81fd..e9279bd625 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -27,7 +27,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet case proFeatures case proExpiryUnixTimestampMs - case proGenIndexHash + case proGenIndexHashHex } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -60,7 +60,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet public let proExpiryUnixTimestampMs: UInt64 /// The timestamp when Session Pro expires for this profile - public let proGenIndexHash: String? + public let proGenIndexHashHex: String? // MARK: - Initialization @@ -74,7 +74,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet blocksCommunityMessageRequests: Bool? = nil, proFeatures: SessionPro.Features = .none, proExpiryUnixTimestampMs: UInt64 = 0, - proGenIndexHash: String? = nil + proGenIndexHashHex: String? = nil ) { self.id = id self.name = name @@ -85,7 +85,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet self.blocksCommunityMessageRequests = blocksCommunityMessageRequests self.proFeatures = proFeatures self.proExpiryUnixTimestampMs = proExpiryUnixTimestampMs - self.proGenIndexHash = proGenIndexHash + self.proGenIndexHashHex = proGenIndexHashHex } } @@ -114,7 +114,7 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), proFeatures: \(proFeatures), proExpiryUnixTimestampMs: \(proExpiryUnixTimestampMs), - proGenIndexHash: \(proGenIndexHash.map { "\($0)" } ?? "null") + proGenIndexHashHex: \(proGenIndexHashHex.map { "\($0)" } ?? "null") ) """ } @@ -233,7 +233,7 @@ public extension Profile { blocksCommunityMessageRequests: nil, proFeatures: .none, proExpiryUnixTimestampMs: 0, - proGenIndexHash: nil + proGenIndexHashHex: nil ) } @@ -447,7 +447,7 @@ public extension Profile { blocksCommunityMessageRequests: Update = .useExisting, proFeatures: Update = .useExisting, proExpiryUnixTimestampMs: Update = .useExisting, - proGenIndexHash: Update = .useExisting + proGenIndexHashHex: Update = .useExisting ) -> Profile { return Profile( id: id, @@ -459,7 +459,7 @@ public extension Profile { blocksCommunityMessageRequests: blocksCommunityMessageRequests.or(self.blocksCommunityMessageRequests), proFeatures: proFeatures.or(self.proFeatures), proExpiryUnixTimestampMs: proExpiryUnixTimestampMs.or(self.proExpiryUnixTimestampMs), - proGenIndexHash: proGenIndexHash.or(self.proGenIndexHash) + proGenIndexHashHex: proGenIndexHashHex.or(self.proGenIndexHashHex) ) } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 4faf1c58ca..472f16b484 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -3,6 +3,7 @@ import Foundation import GRDB import SessionUtil +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Size Restrictions @@ -46,10 +47,8 @@ internal extension LibSessionCacheType { // The current users contact data is handled separately so exclude it if it's present (as that's // actually a bug) - let targetContactData: [String: ContactData] = try LibSession.extractContacts( - from: conf, - using: dependencies - ).filter { $0.key != userSessionId.hexString } + let targetContactData: [String: ContactData] = try extractContacts(from: conf) + .filter { $0.key != userSessionId.hexString } // Since we don't sync 100% of the data stored against the contact and profile objects we // need to only update the data we do have to ensure we don't overwrite anything that doesn't @@ -75,8 +74,24 @@ internal extension LibSessionCacheType { ) }(), nicknameUpdate: .set(to: data.profile.nickname), - proUpdate: .none, // TODO: [PRO] Need to get this somehow? (sync via config? -// sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, + proUpdate: { + guard let proof: Network.SessionPro.ProProof = Network.SessionPro.ProProof(profile: data.profile) else { + return .none + } + + let isActive: Bool = dependencies[singleton: .sessionProManager].proProofIsActive( + for: proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + + return .contactUpdate( + SessionPro.DecodedProForMessage( + status: (isActive ? .valid : .expired), + proProof: proof, + features: data.profile.proFeatures + ) + ) + }(), profileUpdateTimestamp: data.profile.profileLastUpdated, cacheSource: .database, currentUserSessionIds: [userSessionId.hexString], @@ -809,11 +824,8 @@ internal struct ContactData { // MARK: - Convenience -internal extension LibSession { - static func extractContacts( - from conf: UnsafeMutablePointer?, - using dependencies: Dependencies - ) throws -> [String: ContactData] { +internal extension LibSessionCacheType { + func extractContacts(from conf: UnsafeMutablePointer?) throws -> [String: ContactData] { var infiniteLoopGuard: Int = 0 var result: [String: ContactData] = [:] var contact: contacts_contact = contacts_contact() @@ -832,13 +844,19 @@ internal extension LibSession { currentUserSessionId: userSessionId ) let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + let proProofMetadata: LibSession.ProProofMetadata? = self.proProofMetadata( + threadId: contactId + ) let profileResult: Profile = Profile( id: contactId, name: contact.get(\.name), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(contact.profile_updated) + profileLastUpdated: TimeInterval(contact.profile_updated), + proFeatures: SessionPro.Features(contact.pro_features), + proExpiryUnixTimestampMs: (proProofMetadata?.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: proProofMetadata?.genIndexHashHex ) let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration( threadId: contactId, @@ -862,6 +880,19 @@ internal extension LibSession { } } +// MARK: - Convenience + +private extension Network.SessionPro.ProProof { + init?(profile: Profile) { + guard let genIndexHashHex: String = profile.proGenIndexHashHex else { return nil } + + self = Network.SessionPro.ProProof( + genIndexHash: Array(Data(hex: genIndexHashHex)), + expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs + ) + } +} + // MARK: - C Conformance extension contacts_contact: CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index 56d3a80de3..b2c4a268cc 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -171,6 +171,14 @@ internal extension LibSession { case .markedAsUnread(let unread): oneToOne.unread = unread + + case .proProofMetadata(let metadata): + oneToOne.has_pro_gen_index_hash = (metadata != nil) + + guard let metadata: ProProofMetadata = metadata else { return } + + oneToOne.set(\.pro_gen_index_hash, to: Data(hex: metadata.genIndexHashHex)) + oneToOne.pro_expiry_unix_ts_ms = metadata.expiryUnixTimestampMs } } convo_info_volatile_set_1to1(conf, &oneToOne) @@ -195,6 +203,8 @@ internal extension LibSession { case .markedAsUnread(let unread): legacyGroup.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_legacy_group(conf, &legacyGroup) @@ -228,6 +238,8 @@ internal extension LibSession { case .markedAsUnread(let unread): community.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_community(conf, &community) @@ -252,6 +264,8 @@ internal extension LibSession { case .markedAsUnread(let unread): group.unread = unread + + case .proProofMetadata: break /// Unsupported } } convo_info_volatile_set_group(conf, &group) @@ -482,6 +496,59 @@ public extension LibSession.Cache { return group.last_read } } + + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { + /// If it's the current user then source from the `proConfig` instead + guard threadId != userSessionId.hexString else { + return proConfig.map { proConfig in + return LibSession.ProProofMetadata( + genIndexHashHex: proConfig.proProof.genIndexHash.toHexString(), + expiryUnixTimestampMs: proConfig.proProof.expiryUnixTimestampMs + ) + } + } + + /// If we don't have a config then just assume the user is non-pro + guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { + return nil + } + + switch try? SessionId.Prefix(from: threadId) { + case .standard: + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + guard oneToOne.has_pro_gen_index_hash else { return nil } + + return LibSession.ProProofMetadata( + genIndexHashHex: oneToOne.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: oneToOne.pro_expiry_unix_ts_ms + ) + + case .blinded15, .blinded25: + var blinded: convo_info_volatile_blinded_1to1 = convo_info_volatile_blinded_1to1() + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_blinded_1to1(conf, &blinded, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + guard blinded.has_pro_gen_index_hash else { return nil } + + return LibSession.ProProofMetadata( + genIndexHashHex: blinded.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: blinded.pro_expiry_unix_ts_ms + ) + + default: return nil /// Other conversation types don't have `ProProofMetadata` + } + } } // MARK: State Access @@ -506,10 +573,16 @@ public extension LibSessionCacheType { // MARK: - VolatileThreadInfo public extension LibSession { + struct ProProofMetadata { + let genIndexHashHex: String + let expiryUnixTimestampMs: UInt64 + } + struct VolatileThreadInfo { enum Change { case markedAsUnread(Bool) case lastReadTimestampMs(Int64) + case proProofMetadata(ProProofMetadata?) } let threadId: String @@ -626,6 +699,7 @@ public extension LibSession { var community: convo_info_volatile_community = convo_info_volatile_community() var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() var group: convo_info_volatile_group = convo_info_volatile_group() + var blinded: convo_info_volatile_blinded_1to1 = convo_info_volatile_blinded_1to1() let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf) while !convo_info_volatile_iterator_done(convoIterator) { @@ -638,7 +712,15 @@ public extension LibSession { variant: .contact, changes: [ .markedAsUnread(oneToOne.unread), - .lastReadTimestampMs(oneToOne.last_read) + .lastReadTimestampMs(oneToOne.last_read), + .proProofMetadata({ + guard oneToOne.has_pro_gen_index_hash else { return nil } + + return ProProofMetadata( + genIndexHashHex: oneToOne.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: oneToOne.pro_expiry_unix_ts_ms + ) + }()) ] ) ) @@ -689,6 +771,26 @@ public extension LibSession { ) ) } + else if convo_info_volatile_it_is_blinded_1to1(convoIterator, &blinded) { + result.append( + VolatileThreadInfo( + threadId: blinded.get(\.session_id), + variant: .contact, + changes: [ + .markedAsUnread(blinded.unread), + .lastReadTimestampMs(blinded.last_read), + .proProofMetadata({ + guard blinded.has_pro_gen_index_hash else { return nil } + + return ProProofMetadata( + genIndexHashHex: blinded.getHex(\.pro_gen_index_hash), + expiryUnixTimestampMs: blinded.pro_expiry_unix_ts_ms + ) + }()) + ] + ) + ) + } else { Log.error(.libSession, "Ignoring unknown conversation type when iterating through volatile conversation info update") } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 344fcd2b07..dcff48ec07 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -51,8 +51,7 @@ internal extension LibSessionCacheType { func handleGroupInfoUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - groupSessionId: SessionId, - serverTimestampMs: Int64 + groupSessionId: SessionId ) throws { guard configNeedsDump(config) else { return } guard case .groupInfo(let conf) = config else { @@ -152,7 +151,7 @@ internal extension LibSessionCacheType { shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .group(id: groupSessionId.hexString, url: url, encryptionKey: key), - timestamp: TimeInterval(Double(serverTimestampMs) / 1000) + timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) ), canStartJob: true diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 40c6db9120..6bfaec7868 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -170,7 +170,7 @@ internal extension LibSessionCacheType { publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), - proUpdate: .none, // TODO: [PRO] Need to get this somehow? (sync via config? + proUpdate: .none, /// Syncing group member pro state is not supported (changes come from contacts or messages) profileUpdateTimestamp: profile.profileLastUpdated, currentUserSessionIds: [userSessionId.hexString], using: dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 0445e334ad..df8cf95195 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -757,7 +757,7 @@ public extension LibSession.Cache { visibleMessage: VisibleMessage? ) -> Profile? { // FIXME: Once `libSession` manages unsynced "Profile" data we should source this from there - /// Extract the `displayName` directly from the `VisibleMessage` if available and it was sent by the desired contact + /// Extract the `displayName` directly from the `VisibleMessage` if available as it was sent by the desired contact let displayNameInMessage: String? = (visibleMessage?.sender != contactId ? nil : visibleMessage?.profile?.displayName?.nullIfEmpty ) @@ -780,6 +780,8 @@ public extension LibSession.Cache { let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(user_profile_get_profile_updated(conf))) + let proConfig: SessionPro.ProConfig? = self.proConfig + let proFeatures: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) return Profile( id: contactId, @@ -787,7 +789,10 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + proFeatures: proFeatures, + proExpiryUnixTimestampMs: (proConfig?.proProof.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: proConfig.map { $0.proProof.genIndexHash.toHexString() } ) } @@ -817,7 +822,11 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + /// Group members don't sync pro status so try to extract from the provided message + proFeatures: (visibleMessage?.proFeatures ?? .none), + proExpiryUnixTimestampMs: (visibleMessage?.proProof?.expiryUnixTimestampMs ?? 0), + proGenIndexHashHex: visibleMessage?.proProof.map { $0.genIndexHash.toHexString() } ) } @@ -835,15 +844,28 @@ public extension LibSession.Cache { let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(contact.get( \.profile_updated))) + let proFeatures: SessionPro.Features = SessionPro.Features(contact.pro_features) + let proProofMetadata: LibSession.ProProofMetadata? = proProofMetadata(threadId: contactId) - /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + /// The `displayNameInMessage` and other values contained within the message are likely newer than the values stored + /// in the config so use those if available return Profile( id: contactId, name: (displayNameInMessage ?? contact.get(\.name)), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + proFeatures: (visibleMessage?.proFeatures ?? proFeatures), + proExpiryUnixTimestampMs: ( + visibleMessage?.proProof?.expiryUnixTimestampMs ?? + proProofMetadata?.expiryUnixTimestampMs ?? + 0 + ), + proGenIndexHashHex: ( + (visibleMessage?.proProof?.genIndexHash).map { $0.toHexString() } ?? + proProofMetadata?.genIndexHashHex + ) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index e93ee166c5..7315ca79af 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -60,8 +60,30 @@ internal extension LibSessionCacheType { isReupload: false ) }(), - proUpdate: .none, // TODO: [PRO] Do we need to pass this -// sessionProProof: dependencies[singleton: .sessionProManager].currentUserCurrentProProof, + proUpdate: { + guard + let proConfig: SessionPro.ProConfig = self.proConfig, + proConfig.rotatingPrivateKey.count >= 32, + let rotatingKeyPair: KeyPair = try? dependencies[singleton: .crypto].tryGenerate( + .ed25519KeyPair(seed: proConfig.rotatingPrivateKey.prefix(upTo: 32)) + ) + else { return .none } + + let features: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) + let status: SessionPro.ProStatus = dependencies[singleton: .sessionProManager].proStatus( + for: proConfig.proProof, + verifyPubkey: rotatingKeyPair.publicKey, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + + return .currentUserUpdate( + SessionPro.DecodedProForMessage( + status: status, + proProof: proConfig.proProof, + features: features + ) + ) + }(), profileUpdateTimestamp: profileLastUpdateTimestamp, cacheSource: .value((oldState[.profile(userSessionId.hexString)] as? Profile), fallback: .database), suppressUserProfileConfigUpdate: true, @@ -145,6 +167,11 @@ internal extension LibSessionCacheType { db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } + + // Update the SessionProManager with these changes + db.afterCommit { [sessionProManager = dependencies[singleton: .sessionProManager]] in + Task { await sessionProManager.updateWithLatestFromUserConfig() } + } } } @@ -216,10 +243,24 @@ public extension LibSession.Cache { return String(cString: profileNamePtr) } + var proConfig: SessionPro.ProConfig? { + var cProConfig: pro_pro_config = pro_pro_config() + + guard + case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId), + user_profile_get_pro_config(conf, &cProConfig) + else { return nil } + + return SessionPro.ProConfig(cProConfig) + } + + /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, + /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) func updateProfile( displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proFeatures: Update, isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { @@ -235,6 +276,7 @@ public extension LibSession.Cache { let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, nullIfEmpty: true) + let oldProFeatures: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) /// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) /// @@ -253,18 +295,9 @@ public extension LibSession.Cache { } try LibSessionError.throwIfNeeded(conf) - - /// Add a pending observation to notify any observers of the change once it's committed - addEvent( - key: .profile(userSessionId.hexString), - value: ProfileEvent( - id: userSessionId.hexString, - change: .displayPictureUrl(displayPictureUrl.or(oldDisplayPictureUrl)) - ) - ) } - /// Update the nam + /// Update the name /// /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was /// changed (this will be fixed in `libSession v1.5.8`) @@ -274,13 +307,29 @@ public extension LibSession.Cache { }() user_profile_set_name(conf, &cUpdatedName) try LibSessionError.throwIfNeeded(conf) - - /// Add a pending observation to notify any observers of the change once it's committed - addEvent( - key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) - ) } + + /// Update the pro features + /// + /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was + /// changed (this will be fixed in `libSession v1.5.8`) + if proFeatures.or(.none) != oldProFeatures { + user_profile_set_pro_badge(conf, proFeatures.or(.none).contains(.proBadge)) + user_profile_set_animated_avatar(conf, proFeatures.or(.none).contains(.animatedAvatar)) + } + } + + func updateProConfig(proConfig: SessionPro.ProConfig) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + var cProConfig: pro_pro_config = proConfig.libSessionValue + user_profile_set_pro_config(conf, &cProConfig) + } + + func removeProConfig() { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_remove_pro_config(conf) } } diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 4dd9e6be6b..b420a8e747 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -755,61 +755,58 @@ public extension LibSession { .reduce([], +) } + public func currentConfigState( + swarmPublicKey: String, + variants: Set + ) throws -> [ConfigDump.Variant: [ObservableKey: Any]] { + guard !variants.isEmpty else { return [:] } + guard !swarmPublicKey.isEmpty else { throw MessageError.invalidConfigMessageHandling } + + return try variants.reduce(into: [:]) { result, variant in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + + switch configStore[sessionId, variant] { + case .userProfile: + result[variant] = [ + .profile(userSessionId.hexString): profile, + .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests) + ] + + case .contacts(let conf): + result[variant] = try extractContacts(from: conf).reduce(into: [:]) { result, next in + result[.contact(next.key)] = next.value.contact + result[.profile(next.key)] = next.value.profile + } + + default: break + } + } + } + public func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [MergeResult] { - guard !messages.isEmpty else { return [] } + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { + guard !messages.isEmpty else { return [:] } guard !swarmPublicKey.isEmpty else { throw MessageError.invalidConfigMessageHandling } - let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages + return try messages .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) - - return try groupedMessages .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } - .compactMap { variant, messages -> MergeResult? in + .reduce(into: [:]) { result, next in + let (variant, messages): (ConfigDump.Variant, [ConfigMessageReceiveJob.Details.MessageInfo]) = next let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) let config: Config? = configStore[sessionId, variant] do { - let oldState: [ObservableKey: Any] = try { - switch config { - case .userProfile: - return [ - .profile(userSessionId.hexString): profile, - .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests) - ] - - case .contacts(let conf): - return try LibSession - .extractContacts(from: conf, using: dependencies) - .reduce(into: [:]) { result, next in - result[.contact(next.key)] = next.value.contact - result[.profile(next.key)] = next.value.profile - } - - default: return [:] - } - }() - // Merge the messages (if it doesn't merge anything then don't bother trying // to handle the result) Log.info(.libSession, "Attempting to merge \(variant) config messages") guard let latestServerTimestampMs: Int64 = try config?.merge(messages) else { - return nil + return } - // Now that the config message has been merged, run any after-merge logic - let dump: ConfigDump? = try afterMerge( - sessionId, - variant, - config, - latestServerTimestampMs, - oldState - ) - - return (sessionId, variant, dump) + result[variant] = latestServerTimestampMs } catch { Log.error(.libSession, "Failed to process merge of \(variant) config data") @@ -823,90 +820,100 @@ public extension LibSession { swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws { - let results: [MergeResult] = try mergeConfigMessages( + let oldStateMap: [ConfigDump.Variant: [ObservableKey: Any]] = try currentConfigState( + swarmPublicKey: swarmPublicKey, + variants: Set(messages.map { ConfigDump.Variant(namespace: $0.namespace) }) + ) + let latestServerTimestampsMs: [ConfigDump.Variant: Int64] = try mergeConfigMessages( swarmPublicKey: swarmPublicKey, messages: messages - ) { sessionId, variant, config, latestServerTimestampMs, oldState in - // Apply the updated states to the database - switch variant { - case .userProfile: - try handleUserProfileUpdate( - db, - in: config, - oldState: oldState - ) - - case .contacts: - try handleContactsUpdate( - db, - in: config, - oldState: oldState - ) - - case .convoInfoVolatile: - try handleConvoInfoVolatileUpdate( - db, - in: config - ) - - case .userGroups: - try handleUserGroupsUpdate( - db, - in: config - ) - - case .groupInfo: - try handleGroupInfoUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupMembers: - try handleGroupMembersUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupKeys: - try handleGroupKeysUpdate( - db, - in: config, - groupSessionId: sessionId - ) - - case .local: Log.error(.libSession, "Tried to process merge of local config") - case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") - } - - // Need to check if the config needs to be dumped (this might have changed - // after handling the merge changes) - guard configNeedsDump(config) else { - try ConfigDump - .filter( - ConfigDump.Columns.variant == variant && - ConfigDump.Columns.publicKey == sessionId.hexString - ) - .updateAll( - db, - ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) - ) - return nil + ) + let results: [MergeResult] = try latestServerTimestampsMs + .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } + .compactMap { variant, latestServerTimestampMs in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + let config: Config? = configStore[sessionId, variant] + let oldState: [ObservableKey: Any] = (oldStateMap[variant] ?? [:]) + + // Apply the updated states to the database + switch variant { + case .userProfile: + try handleUserProfileUpdate( + db, + in: config, + oldState: oldState + ) + + case .contacts: + try handleContactsUpdate( + db, + in: config, + oldState: oldState + ) + + case .convoInfoVolatile: + try handleConvoInfoVolatileUpdate( + db, + in: config + ) + + case .userGroups: + try handleUserGroupsUpdate( + db, + in: config + ) + + case .groupInfo: + try handleGroupInfoUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .groupMembers: + try handleGroupMembersUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupKeys: + try handleGroupKeysUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .local: Log.error(.libSession, "Tried to process merge of local config") + case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") + } + + // Need to check if the config needs to be dumped (this might have changed + // after handling the merge changes) + guard configNeedsDump(config) else { + try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == sessionId.hexString + ) + .updateAll( + db, + ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) + ) + return nil + } + + let dump: ConfigDump? = try createDump( + config: config, + for: variant, + sessionId: sessionId, + timestampMs: latestServerTimestampMs + ) + try dump?.upsert(db) + + return (sessionId, variant, dump) } - - let dump: ConfigDump? = try createDump( - config: config, - for: variant, - sessionId: sessionId, - timestampMs: latestServerTimestampMs - ) - try dump?.upsert(db) - - return dump - } let needsPush: Bool = (try? SessionId(from: swarmPublicKey)).map { configStore[$0].contains(where: { $0.needsPush }) && @@ -943,23 +950,6 @@ public extension LibSession { } } } - - public func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws { - guard !messages.isEmpty else { return } - - let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages - .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) - - try groupedMessages - .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } - .forEach { [configStore] variant, message in - let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) - _ = try configStore[sessionId, variant]?.merge(message) - } - } } } @@ -1044,24 +1034,14 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] func handleConfigMessages( _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws - /// This function takes config messages and just triggers the merge into `libSession` - /// - /// **Note:** This function should only be used in a situation where we want to retrieve the data from a config message as using it - /// elsewhere will result in the database getting out of sync with the config state - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws - // MARK: - SettingFetcher func has(_ key: Setting.BoolKey) -> Bool @@ -1075,13 +1055,19 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func set(_ key: Setting.EnumKey, _ value: T?) var displayName: String? { get } + var proConfig: SessionPro.ProConfig? { get } + /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, + /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) func updateProfile( displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proFeatures: Update, isReuploadProfilePicture: Bool ) throws + func updateProConfig(proConfig: SessionPro.ProConfig) + func removeProConfig() func canPerformChange( threadId: String, @@ -1107,6 +1093,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? /// Returns whether the specified conversation is a message request /// @@ -1226,6 +1213,7 @@ public extension LibSessionCacheType { displayName: .set(to: displayName), displayPictureUrl: .useExisting, displayPictureEncryptionKey: .useExisting, + proFeatures: .useExisting, isReuploadProfilePicture: false ) } @@ -1332,18 +1320,13 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func activeHashes(for swarmPublicKey: String) -> [String] { return [] } func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] { return [] } + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { return [:] } func handleConfigMessages( _ db: ObservingDatabase, swarmPublicKey: String, messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws {} - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws {} // MARK: - SettingFetcher @@ -1355,6 +1338,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { // MARK: - State Access var displayName: String? { return nil } + var proConfig: SessionPro.ProConfig? { return nil } func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} @@ -1362,8 +1346,11 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proFeatures: Update, isReuploadProfilePicture: Bool ) throws {} + func updateProConfig(proConfig: SessionPro.ProConfig) {} + func removeProConfig() {} func canPerformChange( threadId: String, @@ -1389,6 +1376,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { threadVariant: SessionThread.Variant, openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? { return nil } + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { return nil } func isMessageRequest( threadId: String, @@ -1479,7 +1467,7 @@ private extension Int32 { } } -private extension SessionId { +public extension SessionId { init(hex: String, dumpVariant: ConfigDump.Variant) { switch (try? SessionId(from: hex), dumpVariant) { case (.some(let sessionId), _): self = sessionId diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index e51a8eec20..2eb573cf52 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -22,6 +22,7 @@ public class Message: Codable { case expiresStartedAtMs case proFeatures + case proProof } public var id: String? @@ -45,7 +46,8 @@ public class Message: Codable { public var expiresInSeconds: TimeInterval? public var expiresStartedAtMs: Double? - public var proFeatures: UInt64? + public var proProof: Network.SessionPro.ProProof? + public var proFeatures: SessionPro.Features? // MARK: - Validation @@ -107,7 +109,8 @@ public class Message: Codable { serverHash: String? = nil, expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, - proFeatures: UInt64? = nil + proProof: Network.SessionPro.ProProof? = nil, + proFeatures: SessionPro.Features? = nil ) { self.id = id self.sentTimestampMs = sentTimestampMs @@ -120,6 +123,7 @@ public class Message: Codable { self.serverHash = serverHash self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs + self.proProof = proProof self.proFeatures = proFeatures } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index d28a3937cf..307062a313 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -12,7 +12,6 @@ public extension VisibleMessage { public let profilePictureUrl: String? public let updateTimestampSeconds: TimeInterval? public let blocksCommunityMessageRequests: Bool? - public let proFeatures: SessionPro.Features? // MARK: - Initialization @@ -21,8 +20,7 @@ public extension VisibleMessage { profileKey: Data? = nil, profilePictureUrl: String? = nil, updateTimestampSeconds: TimeInterval? = nil, - blocksCommunityMessageRequests: Bool? = nil, - proFeatures: SessionPro.Features? = nil + blocksCommunityMessageRequests: Bool? = nil ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) @@ -31,7 +29,6 @@ public extension VisibleMessage { self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) self.updateTimestampSeconds = updateTimestampSeconds self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.proFeatures = proFeatures } internal init(profile: Profile, blocksCommunityMessageRequests: Bool? = nil) { @@ -40,8 +37,7 @@ public extension VisibleMessage { profileKey: profile.displayPictureEncryptionKey, profilePictureUrl: profile.displayPictureUrl, updateTimestampSeconds: profile.profileLastUpdated, - blocksCommunityMessageRequests: blocksCommunityMessageRequests, - proFeatures: profile.proFeatures + blocksCommunityMessageRequests: blocksCommunityMessageRequests ) } @@ -61,8 +57,7 @@ public extension VisibleMessage { blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil - ), - proFeatures: nil // TODO: [PRO] Add these once the protobuf is updated + ) ) } @@ -87,7 +82,7 @@ public extension VisibleMessage { } dataMessageProto.setProfile(try profileProto.build()) - // TODO: [PRO] Add the 'proFeatures' value once the protobuf is updated + return dataMessageProto } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index dc62956bc2..1c956b29ed 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionNetworkingKit import SessionUtilitiesKit public final class VisibleMessage: Message { @@ -59,7 +60,9 @@ public final class VisibleMessage: Message { linkPreview: VMLinkPreview? = nil, profile: VMProfile? = nil, // Added when sending via the `MessageWithProfile` protocol openGroupInvitation: VMOpenGroupInvitation? = nil, - reaction: VMReaction? = nil + reaction: VMReaction? = nil, + proProof: Network.SessionPro.ProProof? = nil, + proFeatures: SessionPro.Features? = nil ) { self.syncTarget = syncTarget self.text = text @@ -73,7 +76,9 @@ public final class VisibleMessage: Message { super.init( sentTimestampMs: sentTimestampMs, - sender: sender + sender: sender, + proProof: proProof, + proFeatures: proFeatures ) } @@ -116,6 +121,30 @@ public final class VisibleMessage: Message { public override class func fromProto(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) -> VisibleMessage? { guard let dataMessage = proto.dataMessage else { return nil } + let proInfo: (proof: Network.SessionPro.ProProof, features: SessionPro.Features)? = proto.proMessage + .map { proMessage -> (proof: Network.SessionPro.ProProof, features: SessionPro.Features)? in + guard + let vmProof: SNProtoProProof = proMessage.proof, + vmProof.hasVersion, + vmProof.version <= UInt8.max, /// Sanity check - Protobuf only supports `UInt32`/`UInt64` + vmProof.hasExpiryUnixTs, + let vmGenIndexHash: Data = vmProof.genIndexHash, + let vmRotatingPublicKey: Data = vmProof.rotatingPublicKey, + let vmSig: Data = vmProof.sig + else { return nil } + + return ( + Network.SessionPro.ProProof( + version: UInt8(vmProof.version), + genIndexHash: Array(vmGenIndexHash), + rotatingPubkey: Array(vmRotatingPublicKey), + expiryUnixTimestampMs: vmProof.expiryUnixTs, + signature: Array(vmSig) + ), + SessionPro.Features(proMessage.features) + ) + } + return VisibleMessage( syncTarget: dataMessage.syncTarget, text: dataMessage.body, @@ -125,7 +154,9 @@ public final class VisibleMessage: Message { linkPreview: dataMessage.preview.first.map { VMLinkPreview.fromProto($0) }, profile: VMProfile.fromProto(dataMessage), openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) }, - reaction: dataMessage.reaction.map { VMReaction.fromProto($0) } + reaction: dataMessage.reaction.map { VMReaction.fromProto($0) }, + proProof: proInfo?.proof, + proFeatures: proInfo?.features ) } @@ -182,6 +213,30 @@ public final class VisibleMessage: Message { dataMessage.setSyncTarget(syncTarget) } + // Pro content + if + let proProof: Network.SessionPro.ProProof = proProof, + let proFeatures: SessionPro.Features = proFeatures, + proFeatures != .none + { + let proMessageBuilder: SNProtoProMessage.SNProtoProMessageBuilder = SNProtoProMessage.builder() + let proofBuilder: SNProtoProProof.SNProtoProProofBuilder = SNProtoProProof.builder() + proofBuilder.setVersion(UInt32(proProof.version)) + proofBuilder.setGenIndexHash(Data(proProof.genIndexHash)) + proofBuilder.setRotatingPublicKey(Data(proProof.rotatingPubkey)) + proofBuilder.setExpiryUnixTs(proProof.expiryUnixTimestampMs) + proofBuilder.setSig(Data(proProof.signature)) + + do { + proMessageBuilder.setProof(try proofBuilder.build()) + proMessageBuilder.setFeatures(proFeatures.rawValue) + + proto.setProMessage(try proMessageBuilder.build()) + } catch { + Log.warn(.messageSender, "Couldn't attach pro proof to message due to error: \(error).") + } + } + // Build do { proto.setDataMessage(try dataMessage.build()) diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 633e60c3a7..d90f9381d5 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -648,6 +648,12 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { if hasSigTimestamp { builder.setSigTimestamp(sigTimestamp) } + if let _value = proMessage { + builder.setProMessage(_value) + } + if let _value = proSigForCommunityMessageOnly { + builder.setProSigForCommunityMessageOnly(_value) + } return builder } @@ -697,6 +703,14 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { proto.sigTimestamp = valueParam } + @objc public func setProMessage(_ valueParam: SNProtoProMessage) { + proto.proMessage = valueParam.proto + } + + @objc public func setProSigForCommunityMessageOnly(_ valueParam: Data) { + proto.proSigForCommunityMessageOnly = valueParam + } + @objc public func build() throws -> SNProtoContent { return try SNProtoContent.parseProto(proto) } @@ -722,6 +736,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { @objc public let messageRequestResponse: SNProtoMessageRequestResponse? + @objc public let proMessage: SNProtoProMessage? + @objc public var expirationType: SNProtoContentExpirationType { return SNProtoContent.SNProtoContentExpirationTypeWrap(proto.expirationType) } @@ -743,6 +759,16 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { return proto.hasSigTimestamp } + @objc public var proSigForCommunityMessageOnly: Data? { + guard proto.hasProSigForCommunityMessageOnly else { + return nil + } + return proto.proSigForCommunityMessageOnly + } + @objc public var hasProSigForCommunityMessageOnly: Bool { + return proto.hasProSigForCommunityMessageOnly + } + private init(proto: SessionProtos_Content, dataMessage: SNProtoDataMessage?, callMessage: SNProtoCallMessage?, @@ -750,7 +776,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { typingMessage: SNProtoTypingMessage?, dataExtractionNotification: SNProtoDataExtractionNotification?, unsendRequest: SNProtoUnsendRequest?, - messageRequestResponse: SNProtoMessageRequestResponse?) { + messageRequestResponse: SNProtoMessageRequestResponse?, + proMessage: SNProtoProMessage?) { self.proto = proto self.dataMessage = dataMessage self.callMessage = callMessage @@ -759,6 +786,7 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { self.dataExtractionNotification = dataExtractionNotification self.unsendRequest = unsendRequest self.messageRequestResponse = messageRequestResponse + self.proMessage = proMessage } @objc @@ -807,6 +835,11 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { messageRequestResponse = try SNProtoMessageRequestResponse.parseProto(proto.messageRequestResponse) } + var proMessage: SNProtoProMessage? = nil + if proto.hasProMessage { + proMessage = try SNProtoProMessage.parseProto(proto.proMessage) + } + // MARK: - Begin Validation Logic for SNProtoContent - // MARK: - End Validation Logic for SNProtoContent - @@ -818,7 +851,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { typingMessage: typingMessage, dataExtractionNotification: dataExtractionNotification, unsendRequest: unsendRequest, - messageRequestResponse: messageRequestResponse) + messageRequestResponse: messageRequestResponse, + proMessage: proMessage) return result } @@ -4025,3 +4059,267 @@ extension SNProtoGroupUpdateDeleteMemberContentMessage.SNProtoGroupUpdateDeleteM } #endif + +// MARK: - SNProtoProProof + +@objc public class SNProtoProProof: NSObject { + + // MARK: - SNProtoProProofBuilder + + @objc public class func builder() -> SNProtoProProofBuilder { + return SNProtoProProofBuilder() + } + + // asBuilder() constructs a builder that reflects the proto's contents. + @objc public func asBuilder() -> SNProtoProProofBuilder { + let builder = SNProtoProProofBuilder() + if hasVersion { + builder.setVersion(version) + } + if let _value = genIndexHash { + builder.setGenIndexHash(_value) + } + if let _value = rotatingPublicKey { + builder.setRotatingPublicKey(_value) + } + if hasExpiryUnixTs { + builder.setExpiryUnixTs(expiryUnixTs) + } + if let _value = sig { + builder.setSig(_value) + } + return builder + } + + @objc public class SNProtoProProofBuilder: NSObject { + + private var proto = SessionProtos_ProProof() + + @objc fileprivate override init() {} + + @objc public func setVersion(_ valueParam: UInt32) { + proto.version = valueParam + } + + @objc public func setGenIndexHash(_ valueParam: Data) { + proto.genIndexHash = valueParam + } + + @objc public func setRotatingPublicKey(_ valueParam: Data) { + proto.rotatingPublicKey = valueParam + } + + @objc public func setExpiryUnixTs(_ valueParam: UInt64) { + proto.expiryUnixTs = valueParam + } + + @objc public func setSig(_ valueParam: Data) { + proto.sig = valueParam + } + + @objc public func build() throws -> SNProtoProProof { + return try SNProtoProProof.parseProto(proto) + } + + @objc public func buildSerializedData() throws -> Data { + return try SNProtoProProof.parseProto(proto).serializedData() + } + } + + fileprivate let proto: SessionProtos_ProProof + + @objc public var version: UInt32 { + return proto.version + } + @objc public var hasVersion: Bool { + return proto.hasVersion + } + + @objc public var genIndexHash: Data? { + guard proto.hasGenIndexHash else { + return nil + } + return proto.genIndexHash + } + @objc public var hasGenIndexHash: Bool { + return proto.hasGenIndexHash + } + + @objc public var rotatingPublicKey: Data? { + guard proto.hasRotatingPublicKey else { + return nil + } + return proto.rotatingPublicKey + } + @objc public var hasRotatingPublicKey: Bool { + return proto.hasRotatingPublicKey + } + + @objc public var expiryUnixTs: UInt64 { + return proto.expiryUnixTs + } + @objc public var hasExpiryUnixTs: Bool { + return proto.hasExpiryUnixTs + } + + @objc public var sig: Data? { + guard proto.hasSig else { + return nil + } + return proto.sig + } + @objc public var hasSig: Bool { + return proto.hasSig + } + + private init(proto: SessionProtos_ProProof) { + self.proto = proto + } + + @objc + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + @objc public class func parseData(_ serializedData: Data) throws -> SNProtoProProof { + let proto = try SessionProtos_ProProof(serializedData: serializedData) + return try parseProto(proto) + } + + fileprivate class func parseProto(_ proto: SessionProtos_ProProof) throws -> SNProtoProProof { + // MARK: - Begin Validation Logic for SNProtoProProof - + + // MARK: - End Validation Logic for SNProtoProProof - + + let result = SNProtoProProof(proto: proto) + return result + } + + @objc public override var debugDescription: String { + return "\(proto)" + } +} + +#if DEBUG + +extension SNProtoProProof { + @objc public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension SNProtoProProof.SNProtoProProofBuilder { + @objc public func buildIgnoringErrors() -> SNProtoProProof? { + return try! self.build() + } +} + +#endif + +// MARK: - SNProtoProMessage + +@objc public class SNProtoProMessage: NSObject { + + // MARK: - SNProtoProMessageBuilder + + @objc public class func builder() -> SNProtoProMessageBuilder { + return SNProtoProMessageBuilder() + } + + // asBuilder() constructs a builder that reflects the proto's contents. + @objc public func asBuilder() -> SNProtoProMessageBuilder { + let builder = SNProtoProMessageBuilder() + if let _value = proof { + builder.setProof(_value) + } + if hasFeatures { + builder.setFeatures(features) + } + return builder + } + + @objc public class SNProtoProMessageBuilder: NSObject { + + private var proto = SessionProtos_ProMessage() + + @objc fileprivate override init() {} + + @objc public func setProof(_ valueParam: SNProtoProProof) { + proto.proof = valueParam.proto + } + + @objc public func setFeatures(_ valueParam: UInt64) { + proto.features = valueParam + } + + @objc public func build() throws -> SNProtoProMessage { + return try SNProtoProMessage.parseProto(proto) + } + + @objc public func buildSerializedData() throws -> Data { + return try SNProtoProMessage.parseProto(proto).serializedData() + } + } + + fileprivate let proto: SessionProtos_ProMessage + + @objc public let proof: SNProtoProProof? + + @objc public var features: UInt64 { + return proto.features + } + @objc public var hasFeatures: Bool { + return proto.hasFeatures + } + + private init(proto: SessionProtos_ProMessage, + proof: SNProtoProProof?) { + self.proto = proto + self.proof = proof + } + + @objc + public func serializedData() throws -> Data { + return try self.proto.serializedData() + } + + @objc public class func parseData(_ serializedData: Data) throws -> SNProtoProMessage { + let proto = try SessionProtos_ProMessage(serializedData: serializedData) + return try parseProto(proto) + } + + fileprivate class func parseProto(_ proto: SessionProtos_ProMessage) throws -> SNProtoProMessage { + var proof: SNProtoProProof? = nil + if proto.hasProof { + proof = try SNProtoProProof.parseProto(proto.proof) + } + + // MARK: - Begin Validation Logic for SNProtoProMessage - + + // MARK: - End Validation Logic for SNProtoProMessage - + + let result = SNProtoProMessage(proto: proto, + proof: proof) + return result + } + + @objc public override var debugDescription: String { + return "\(proto)" + } +} + +#if DEBUG + +extension SNProtoProMessage { + @objc public func serializedDataIgnoringErrors() -> Data? { + return try! self.serializedData() + } +} + +extension SNProtoProMessage.SNProtoProMessageBuilder { + @objc public func buildIgnoringErrors() -> SNProtoProMessage? { + return try! self.build() + } +} + +#endif diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 40f49dead2..709fb33585 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -357,6 +357,9 @@ struct SessionProtos_Content { /// Clears the value of `expirationTimer`. Subsequent reads from it will return its default value. mutating func clearExpirationTimer() {_uniqueStorage()._expirationTimer = nil} + /// NOTE: This timestamp was added to address the issue with 1o1 message envelope timestamps were + /// unauthenticated because 1o1 messages encrypt the Content not the envelope. In Groups, the + /// entire envelope is encrypted and hence can be trusted. var sigTimestamp: UInt64 { get {return _storage._sigTimestamp ?? 0} set {_uniqueStorage()._sigTimestamp = newValue} @@ -366,6 +369,76 @@ struct SessionProtos_Content { /// Clears the value of `sigTimestamp`. Subsequent reads from it will return its default value. mutating func clearSigTimestamp() {_uniqueStorage()._sigTimestamp = nil} + var proMessage: SessionProtos_ProMessage { + get {return _storage._proMessage ?? SessionProtos_ProMessage()} + set {_uniqueStorage()._proMessage = newValue} + } + /// Returns true if `proMessage` has been explicitly set. + var hasProMessage: Bool {return _storage._proMessage != nil} + /// Clears the value of `proMessage`. Subsequent reads from it will return its default value. + mutating func clearProMessage() {_uniqueStorage()._proMessage = nil} + + /// NOTE: Temporary transition field to include the pro-signature into Content for community + /// messages to use. + /// + /// Community messages are currently sent and received as plaintext Content. We call this state of + /// the network v0. + /// + /// We will continue to send Community messages using the Content structure, but, now enhanced with + /// the optional `proSigForCommunityMessageOnly` field which contains the pro signature. We call + /// this network v1. The new clients running v1 will pack the pro-signature into the payload. We + /// maintain forwards compatibility with clients on v0 as we are still sending content + /// on the wire, they skip the new pro data. + /// + /// Simultaneously in v1 the responsibility of parsing the open groups messages will go into + /// libsession. Libsession will be setup to try and parse the open groups message as a `Content` + /// message at first, if that fails it will try to read the community message as an `Envelope`. + /// In summary in a v1 network: + /// + /// v0 will still receive messages from v1 as they send `Content` community messages. + /// + /// v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + /// `Content` community messages so that there's compatibility with v0. + /// + /// After a defined transitionary period, we create a new release and update libsession to stop + /// sending `Content` for communities and transition to sending `Envelope` for messages. We mark + /// this as a v2 network: + /// + /// v0 will still receive messages from v1 (`Content`) but not v2 (`Envelope`) community + /// messages. + /// + /// v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + /// `Content` community messages so that there's compatibility with v0. + /// + /// v2 swaps the parsing order. it tries parsing v1 (`envelope`) then v0 (`content`) from a + /// community message. v2 sends `envelope` community messages so compatbility is maintained with + /// v1 but not v0. + /// + /// After a final transitionary period, v3, remove parsing content entirely from libsession for + /// community messages and removes the pro-signature from `content`. in this final stage, v2 and v3 + /// are the final set of clients that can continue to talk to each other. + /// + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | Version | Sends | Receives v0 | Receives v1 | Receives v2 | Receives v3 | + /// | | | (Content) | (Content+ProSig) | (Envelope) | (Envelope) | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v0 | Content | Yes | Yes | No | No | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v1 | Content+ProSig | Yes | Yes | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v2 | Envelope | Yes | Yes | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + /// | v3 | Envelope | No | No | Yes | Yes | + /// +---------+----------------+-------------+------------------+-------------+-------------+ + var proSigForCommunityMessageOnly: Data { + get {return _storage._proSigForCommunityMessageOnly ?? Data()} + set {_uniqueStorage()._proSigForCommunityMessageOnly = newValue} + } + /// Returns true if `proSigForCommunityMessageOnly` has been explicitly set. + var hasProSigForCommunityMessageOnly: Bool {return _storage._proSigForCommunityMessageOnly != nil} + /// Clears the value of `proSigForCommunityMessageOnly`. Subsequent reads from it will return its default value. + mutating func clearProSigForCommunityMessageOnly() {_uniqueStorage()._proSigForCommunityMessageOnly = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() enum ExpirationType: SwiftProtobuf.Enum { @@ -1707,6 +1780,102 @@ struct SessionProtos_GroupUpdateDeleteMemberContentMessage { fileprivate var _adminSignature: Data? = nil } +struct SessionProtos_ProProof { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var version: UInt32 { + get {return _version ?? 0} + set {_version = newValue} + } + /// Returns true if `version` has been explicitly set. + var hasVersion: Bool {return self._version != nil} + /// Clears the value of `version`. Subsequent reads from it will return its default value. + mutating func clearVersion() {self._version = nil} + + /// Opaque identifier of this proof produced by the Session Pro backend + var genIndexHash: Data { + get {return _genIndexHash ?? Data()} + set {_genIndexHash = newValue} + } + /// Returns true if `genIndexHash` has been explicitly set. + var hasGenIndexHash: Bool {return self._genIndexHash != nil} + /// Clears the value of `genIndexHash`. Subsequent reads from it will return its default value. + mutating func clearGenIndexHash() {self._genIndexHash = nil} + + /// Public key whose signatures is authorised to entitle messages with Session Pro + var rotatingPublicKey: Data { + get {return _rotatingPublicKey ?? Data()} + set {_rotatingPublicKey = newValue} + } + /// Returns true if `rotatingPublicKey` has been explicitly set. + var hasRotatingPublicKey: Bool {return self._rotatingPublicKey != nil} + /// Clears the value of `rotatingPublicKey`. Subsequent reads from it will return its default value. + mutating func clearRotatingPublicKey() {self._rotatingPublicKey = nil} + + /// Epoch timestamps in milliseconds + var expiryUnixTs: UInt64 { + get {return _expiryUnixTs ?? 0} + set {_expiryUnixTs = newValue} + } + /// Returns true if `expiryUnixTs` has been explicitly set. + var hasExpiryUnixTs: Bool {return self._expiryUnixTs != nil} + /// Clears the value of `expiryUnixTs`. Subsequent reads from it will return its default value. + mutating func clearExpiryUnixTs() {self._expiryUnixTs = nil} + + /// Signature produced by the Session Pro Backend signing over the hash of the proof + var sig: Data { + get {return _sig ?? Data()} + set {_sig = newValue} + } + /// Returns true if `sig` has been explicitly set. + var hasSig: Bool {return self._sig != nil} + /// Clears the value of `sig`. Subsequent reads from it will return its default value. + mutating func clearSig() {self._sig = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _version: UInt32? = nil + fileprivate var _genIndexHash: Data? = nil + fileprivate var _rotatingPublicKey: Data? = nil + fileprivate var _expiryUnixTs: UInt64? = nil + fileprivate var _sig: Data? = nil +} + +struct SessionProtos_ProMessage { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var proof: SessionProtos_ProProof { + get {return _proof ?? SessionProtos_ProProof()} + set {_proof = newValue} + } + /// Returns true if `proof` has been explicitly set. + var hasProof: Bool {return self._proof != nil} + /// Clears the value of `proof`. Subsequent reads from it will return its default value. + mutating func clearProof() {self._proof = nil} + + var features: UInt64 { + get {return _features ?? 0} + set {_features = newValue} + } + /// Returns true if `features` has been explicitly set. + var hasFeatures: Bool {return self._features != nil} + /// Clears the value of `features`. Subsequent reads from it will return its default value. + mutating func clearFeatures() {self._features = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _proof: SessionProtos_ProProof? = nil + fileprivate var _features: UInt64? = nil +} + #if swift(>=5.5) && canImport(_Concurrency) extension SessionProtos_Envelope: @unchecked Sendable {} extension SessionProtos_Envelope.TypeEnum: @unchecked Sendable {} @@ -1746,6 +1915,8 @@ extension SessionProtos_GroupUpdateMemberLeftMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateMemberLeftNotificationMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateInviteResponseMessage: @unchecked Sendable {} extension SessionProtos_GroupUpdateDeleteMemberContentMessage: @unchecked Sendable {} +extension SessionProtos_ProProof: @unchecked Sendable {} +extension SessionProtos_ProMessage: @unchecked Sendable {} #endif // swift(>=5.5) && canImport(_Concurrency) // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -2000,6 +2171,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm 12: .same(proto: "expirationType"), 13: .same(proto: "expirationTimer"), 15: .same(proto: "sigTimestamp"), + 16: .same(proto: "proMessage"), + 17: .same(proto: "proSigForCommunityMessageOnly"), ] fileprivate class _StorageClass { @@ -2013,6 +2186,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm var _expirationType: SessionProtos_Content.ExpirationType? = nil var _expirationTimer: UInt32? = nil var _sigTimestamp: UInt64? = nil + var _proMessage: SessionProtos_ProMessage? = nil + var _proSigForCommunityMessageOnly: Data? = nil static let defaultInstance = _StorageClass() @@ -2029,6 +2204,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm _expirationType = source._expirationType _expirationTimer = source._expirationTimer _sigTimestamp = source._sigTimestamp + _proMessage = source._proMessage + _proSigForCommunityMessageOnly = source._proSigForCommunityMessageOnly } } @@ -2070,6 +2247,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm case 12: try { try decoder.decodeSingularEnumField(value: &_storage._expirationType) }() case 13: try { try decoder.decodeSingularUInt32Field(value: &_storage._expirationTimer) }() case 15: try { try decoder.decodeSingularUInt64Field(value: &_storage._sigTimestamp) }() + case 16: try { try decoder.decodeSingularMessageField(value: &_storage._proMessage) }() + case 17: try { try decoder.decodeSingularBytesField(value: &_storage._proSigForCommunityMessageOnly) }() default: break } } @@ -2112,6 +2291,12 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm try { if let v = _storage._sigTimestamp { try visitor.visitSingularUInt64Field(value: v, fieldNumber: 15) } }() + try { if let v = _storage._proMessage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 16) + } }() + try { if let v = _storage._proSigForCommunityMessageOnly { + try visitor.visitSingularBytesField(value: v, fieldNumber: 17) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2131,6 +2316,8 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if _storage._expirationType != rhs_storage._expirationType {return false} if _storage._expirationTimer != rhs_storage._expirationTimer {return false} if _storage._sigTimestamp != rhs_storage._sigTimestamp {return false} + if _storage._proMessage != rhs_storage._proMessage {return false} + if _storage._proSigForCommunityMessageOnly != rhs_storage._proSigForCommunityMessageOnly {return false} return true } if !storagesAreEqual {return false} @@ -3527,3 +3714,105 @@ extension SessionProtos_GroupUpdateDeleteMemberContentMessage: SwiftProtobuf.Mes return true } } + +extension SessionProtos_ProProof: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProProof" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "version"), + 2: .same(proto: "genIndexHash"), + 3: .same(proto: "rotatingPublicKey"), + 4: .same(proto: "expiryUnixTs"), + 5: .same(proto: "sig"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self._version) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self._genIndexHash) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self._rotatingPublicKey) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self._expiryUnixTs) }() + case 5: try { try decoder.decodeSingularBytesField(value: &self._sig) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._version { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + } }() + try { if let v = self._genIndexHash { + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + } }() + try { if let v = self._rotatingPublicKey { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } }() + try { if let v = self._expiryUnixTs { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._sig { + try visitor.visitSingularBytesField(value: v, fieldNumber: 5) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: SessionProtos_ProProof, rhs: SessionProtos_ProProof) -> Bool { + if lhs._version != rhs._version {return false} + if lhs._genIndexHash != rhs._genIndexHash {return false} + if lhs._rotatingPublicKey != rhs._rotatingPublicKey {return false} + if lhs._expiryUnixTs != rhs._expiryUnixTs {return false} + if lhs._sig != rhs._sig {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SessionProtos_ProMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ProMessage" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "proof"), + 2: .same(proto: "features"), + ] + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._proof) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self._features) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._proof { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = self._features { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: SessionProtos_ProMessage, rhs: SessionProtos_ProMessage) -> Bool { + if lhs._proof != rhs._proof {return false} + if lhs._features != rhs._features {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index dd848b18e9..cd54516ba3 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -68,7 +68,66 @@ message Content { optional MessageRequestResponse messageRequestResponse = 10; optional ExpirationType expirationType = 12; optional uint32 expirationTimer = 13; + + // NOTE: This timestamp was added to address the issue with 1o1 message envelope timestamps were + // unauthenticated because 1o1 messages encrypt the Content not the envelope. In Groups, the + // entire envelope is encrypted and hence can be trusted. optional uint64 sigTimestamp = 15; + optional ProMessage proMessage = 16; + + // NOTE: Temporary transition field to include the pro-signature into Content for community + // messages to use. + // + // Community messages are currently sent and received as plaintext Content. We call this state of + // the network v0. + // + // We will continue to send Community messages using the Content structure, but, now enhanced with + // the optional `proSigForCommunityMessageOnly` field which contains the pro signature. We call + // this network v1. The new clients running v1 will pack the pro-signature into the payload. We + // maintain forwards compatibility with clients on v0 as we are still sending content + // on the wire, they skip the new pro data. + // + // Simultaneously in v1 the responsibility of parsing the open groups messages will go into + // libsession. Libsession will be setup to try and parse the open groups message as a `Content` + // message at first, if that fails it will try to read the community message as an `Envelope`. + // In summary in a v1 network: + // + // v0 will still receive messages from v1 as they send `Content` community messages. + // + // v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + // `Content` community messages so that there's compatibility with v0. + // + // After a defined transitionary period, we create a new release and update libsession to stop + // sending `Content` for communities and transition to sending `Envelope` for messages. We mark + // this as a v2 network: + // + // v0 will still receive messages from v1 (`Content`) but not v2 (`Envelope`) community + // messages. + // + // v1 accepts v0 (`Content`) and v1 (`Envelope`) on the wire for community messages. v1 sends + // `Content` community messages so that there's compatibility with v0. + // + // v2 swaps the parsing order. it tries parsing v1 (`envelope`) then v0 (`content`) from a + // community message. v2 sends `envelope` community messages so compatbility is maintained with + // v1 but not v0. + // + // After a final transitionary period, v3, remove parsing content entirely from libsession for + // community messages and removes the pro-signature from `content`. in this final stage, v2 and v3 + // are the final set of clients that can continue to talk to each other. + // + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | Version | Sends | Receives v0 | Receives v1 | Receives v2 | Receives v3 | + // | | | (Content) | (Content+ProSig) | (Envelope) | (Envelope) | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v0 | Content | Yes | Yes | No | No | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v1 | Content+ProSig | Yes | Yes | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v2 | Envelope | Yes | Yes | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + // | v3 | Envelope | No | No | Yes | Yes | + // +---------+----------------+-------------+------------------+-------------+-------------+ + optional bytes proSigForCommunityMessageOnly = 17; } message CallMessage { @@ -308,3 +367,16 @@ message GroupUpdateDeleteMemberContentMessage { repeated string messageHashes = 2; optional bytes adminSignature = 3; } + +message ProProof { + optional uint32 version = 1; + optional bytes genIndexHash = 2; // Opaque identifier of this proof produced by the Session Pro backend + optional bytes rotatingPublicKey = 3; // Public key whose signatures is authorised to entitle messages with Session Pro + optional uint64 expiryUnixTs = 4; // Epoch timestamps in milliseconds + optional bytes sig = 5; // Signature produced by the Session Pro Backend signing over the hash of the proof +} + +message ProMessage { + optional ProProof proof = 1; + optional uint64 features = 2; +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 2c5cba25d5..fd779c9794 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -213,7 +213,7 @@ extension MessageReceiver { // If we received an outgoing message then we can assume the interaction has already // been sent, otherwise we should just use whatever the default state is state: (variant == .standardOutgoing ? .sent : nil), - proFeatures: SessionPro.Features(rawValue: message.proFeatures ?? 0), + proFeatures: (message.proFeatures ?? .none), using: dependencies ).inserted(db) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 23d362b9e2..3816689b04 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -82,6 +82,13 @@ public enum MessageReceiver { message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + /// If the `decodedPro` content on the message is not valid then we should remove any pro content from the message itself + /// as it's invalid + if decodedMessage.decodedPro?.status != .valid { + message.proFeatures = nil + message.proProof = nil + } + /// Perform validation and assign additional message values based on the origin switch origin { case .community(let openGroupId, _, _, let messageServerId, let whisper, let whisperMods, let whisperTo): diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 4ab67ea4f4..92bdcc722c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -378,16 +378,20 @@ public final class MessageSender { throw MessageError.invalidMessage("Attempted to send to namespace \(namespace) via the wrong pipeline") } + /// Add Session Pro data if needed + let finalMessage: Message = dependencies[singleton: .sessionProManager].attachProInfoIfNeeded(message: message) + /// Add attachments if needed and convert to serialised proto data guard - let plaintext: Data = try? message.toProto()? - .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment })? + let plaintext: Data = try? finalMessage.toProto()? + .addingAttachmentsIfNeeded(finalMessage, attachments?.map { $0.attachment })? .serializedData() else { throw MessageError.protoConversionFailed } return try dependencies[singleton: .crypto].tryGenerate( .encodedMessage( plaintext: Array(plaintext), + proFeatures: (finalMessage.proFeatures ?? .none), destination: destination, sentTimestampMs: sentTimestampMs ) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 35e80c18df..ff27da692d 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -29,8 +29,8 @@ public actor SessionProManager: SessionProManagerType { private let dependencies: Dependencies nonisolated private let syncState: SessionProManagerSyncState private var proStatusObservationTask: Task? - private var masterKeyPair: KeyPair? public var rotatingKeyPair: KeyPair? + public var proFeatures: SessionPro.Features = .none nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) @@ -42,9 +42,6 @@ public actor SessionProManager: SessionProManagerType { } nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.backendUserProStatus == .active } nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } - nonisolated public var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { - syncState.decodedProForMessage - } nonisolated public var currentUserIsPro: AsyncStream { backendUserProStatusStream.stream .map { $0 == .active } @@ -67,11 +64,13 @@ public actor SessionProManager: SessionProManagerType { public init(using dependencies: Dependencies) { self.dependencies = dependencies self.syncState = SessionProManagerSyncState(using: dependencies) - self.masterKeyPair = dependencies[singleton: .crypto].generate(.sessionProMasterKeyPair()) Task { await updateWithLatestFromUserConfig() await startProStatusObservations() + + /// Kick off a refresh so we know we have the latest state + try? await refreshProState() } } @@ -115,6 +114,17 @@ public actor SessionProManager: SessionProManagerType { ) } + nonisolated public func proProofIsActive( + for proof: Network.SessionPro.ProProof?, + atTimestampMs timestampMs: UInt64 + ) -> Bool { + guard let proof: Network.SessionPro.ProProof else { return false } + + var cProProof: session_protocol_pro_proof = proof.libSessionValue + + return session_protocol_pro_proof_is_active(&cProProof, timestampMs) + } + nonisolated public func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage { guard let cMessage: [CChar] = message.cString(using: .utf8) else { return SessionPro.FeaturesForMessage.invalidString @@ -129,8 +139,35 @@ public actor SessionProManager: SessionProManagerType { ) } + nonisolated public func attachProInfoIfNeeded(message: Message) -> Message { + let featuresForMessage: SessionPro.FeaturesForMessage = features( + for: ((message as? VisibleMessage)?.text ?? ""), + features: (syncState.proFeatures ?? .none) + ) + + /// We only want to attach the `proFeatures` and `proProof` if a pro feature is _actually_ used + guard + featuresForMessage.status == .success, + featuresForMessage.features != .none, + let proof: Network.SessionPro.ProProof = syncState.proProof + else { + if featuresForMessage.status != .success { + Log.error(.sessionPro, "Failed to get features for outgoing message due to error: \(featuresForMessage.error ?? "Unknown error")") + } + return message + } + + let updatedMessage: Message = message + updatedMessage.proFeatures = featuresForMessage.features + updatedMessage.proProof = proof + + return updatedMessage + } + public func updateWithLatestFromUserConfig() async { - let proConfig: SessionPro.ProConfig? = dependencies.mutate(cache: .libSession) { $0.proConfig } + let (proConfig, profile): (SessionPro.ProConfig?, Profile) = dependencies.mutate(cache: .libSession) { + ($0.proConfig, $0.profile) + } let rotatingKeyPair: KeyPair? = try? proConfig.map { config in guard config.rotatingPrivateKey.count >= 32 else { return nil } @@ -144,20 +181,16 @@ public actor SessionProManager: SessionProManagerType { /// sync state) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - proProof: .set(to: proConfig?.proProof) + proProof: .set(to: proConfig?.proProof), + proFeatures: .set(to: profile.proFeatures) ) /// Then update the async state and streams self.rotatingKeyPair = rotatingKeyPair + self.proFeatures = profile.proFeatures await self.proProofStream.send(proConfig?.proProof) } - public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { - dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .active) - await backendUserProStatusStream.send(.active) - completion?(true) - } - @discardableResult @MainActor public func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, @@ -189,9 +222,202 @@ public actor SessionProManager: SessionProManagerType { return true } + + public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { + // TODO: [PRO] Need to actually implement this + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .active) + await backendUserProStatusStream.send(.active) + completion?(true) + } + + // MARK: - Pro State Management + + public func refreshProState() async throws { + let request = try? Network.SessionPro.getProDetails( + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.GetProDetailsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Failed to retrieve pro details due to error(s): \(errorString)") + throw NetworkError.explicit(errorString) + } + + // TODO: [PRO] Need to add an observable event for the pro status + syncState.update(backendUserProStatus: .set(to: response.status)) + await self.backendUserProStatusStream.send(response.status) + + switch detailsResponse.status { + case .active: + try await refreshProProofIfNeeded( + accessExpiryTimestampMs: response.expiryTimestampMs, + autoRenewing: response.autoRenewing, + status: response.status + ) + + case .neverBeenPro: await clearProProof() + case .expired: await clearProProof() + } + + } + + public func refreshProProofIfNeeded( + accessExpiryTimestampMs: UInt64, + autoRenewing: Bool, + status: BackendUserProStatus + ) async throws { + guard status == .active else { return } + + let needsNewProof: Bool = { + guard let currentProof: Network.SessionPro.ProProof = await proProofStream.getCurrent() else { + return true + } + + let sixtyMinutesBeforeAccessExpiry: UInt64 = (accessExpiryTimestampMs - (60 * 60)) + let sixtyMinutesBeforeProofExpiry: UInt64 = (currentProof.expiryUnixTimestampMs - (60 * 60)) + let now: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) + + return ( + sixtyMinutesBeforeProofExpiry < now && + now < sixtyMinutesBeforeAccessExpiry && + autoRenewing + ) + }() + let rotatingKeyPair: KeyPair = try ( + self.rotatingKeyPair ?? + dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + ) + + let request = try Network.SessionPro.generateProProof( + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Failed to generate new pro proot due to error(s): \(errorString)") + throw NetworkError.explicit(errorString) + } + + /// Update the config + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) + ) + } + } + } + + /// Send the proof and status events on the streams + /// + /// **Note:** We can assume that the users status is `active` since they just successfully generated a pro proof + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + backendUserProStatus: .set(to: .active), + proProof: .set(to: response.proof) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.proProofStream.send(response.proof) + await self.backendUserProStatusStream.send(.active) + } + + public func addProPayment(transactionId: String) async throws { + /// First we need to add the pro payment to the Pro backend + let rotatingKeyPair: KeyPair = try ( + self.rotatingKeyPair ?? + dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + ) + let request = try Network.SessionPro.addProPayment( + transactionId: transactionId, + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Transaction submission failed due to error(s): \(errorString)") + throw NetworkError.explicit(errorString) + } + + /// Update the config + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) + ) + } + } + } + + /// Send the proof and status events on the streams + /// + /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and + /// received a pro proof + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + backendUserProStatus: .set(to: .active), + proProof: .set(to: response.proof) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.proProofStream.send(response.proof) + await self.backendUserProStatusStream.send(.active) + + /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other + /// edge-cases since it's the main driver to the Pro state) + try? await refreshProState() + } // MARK: - Internal Functions + private func clearProProof() async { + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.removeProConfig() + } + } + } + } + + private func updateExpiryCTAs( + accessExpiryTimestampMs: UInt64, + autoRenewing: Bool, + status: BackendUserProStatus + ) async { + let now: UInt64 = UINt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let sevenDaysBeforeExpiry: UInt64 = (accessExpiryTimestampMs - (7 * 60 * 60)) + let thirtyDaysAfterExpiry: UInt64 = (accessExpiryTimestampMs + (30 * 60 * 60)) + + // TODO: [PRO] Need to add these in (likely part of pro settings) + } + private func startProStatusObservations() { proStatusObservationTask?.cancel() proStatusObservationTask = Task { @@ -206,6 +432,8 @@ public actor SessionProManager: SessionProManagerType { continue } + /// Restart the observation (will fetch the correct current states) + await self?.refreshProState() } } @@ -218,6 +446,7 @@ public actor SessionProManager: SessionProManagerType { guard let status: Network.SessionPro.BackendUserProStatus = status else { self?.syncState.update(backendUserProStatus: .set(to: nil)) await self?.backendUserProStatusStream.send(nil) + try? await self?.refreshProState() continue } @@ -248,7 +477,7 @@ private final class SessionProManagerSyncState { private var _rotatingKeyPair: KeyPair? = nil private var _backendUserProStatus: Network.SessionPro.BackendUserProStatus? = nil private var _proProof: Network.SessionPro.ProProof? = nil - private var _decodedProForMessage: SessionPro.DecodedProForMessage? = nil + private var _proFeatures: SessionPro.Features = .none fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } @@ -256,7 +485,7 @@ private final class SessionProManagerSyncState { lock.withLock { _backendUserProStatus } } fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } - fileprivate var decodedProForMessage: SessionPro.DecodedProForMessage? { lock.withLock { _decodedProForMessage } } + fileprivate var proFeatures: SessionPro.Features? { lock.withLock { _proFeatures } } fileprivate init(using dependencies: Dependencies) { self._dependencies = dependencies @@ -266,13 +495,13 @@ private final class SessionProManagerSyncState { rotatingKeyPair: Update = .useExisting, backendUserProStatus: Update = .useExisting, proProof: Update = .useExisting, - decodedProForMessage: Update = .useExisting + proFeatures: Update = .useExisting ) { lock.withLock { self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) self._backendUserProStatus = backendUserProStatus.or(self._backendUserProStatus) self._proProof = proProof.or(self._proProof) - self._decodedProForMessage = decodedProForMessage.or(self._decodedProForMessage) + self._proFeatures = proFeatures.or(self._proFeatures) } } } @@ -286,7 +515,6 @@ public protocol SessionProManagerType: SessionProUIManagerType { nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } nonisolated var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } - nonisolated var currentUserCurrentDecodedProForMessage: SessionPro.DecodedProForMessage? { get } nonisolated var backendUserProStatus: AsyncStream { get } nonisolated var proProof: AsyncStream { get } @@ -296,8 +524,16 @@ public protocol SessionProManagerType: SessionProUIManagerType { verifyPubkey: I?, atTimestampMs timestampMs: UInt64 ) -> SessionPro.ProStatus - func updateWithLatestFromUserConfig() async + nonisolated func proProofIsActive( + for proof: Network.SessionPro.ProProof?, + atTimestampMs timestampMs: UInt64 + ) -> Bool nonisolated func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage + nonisolated func attachProInfoIfNeeded(message: Message) -> Message + func updateWithLatestFromUserConfig() async + + func refreshProState() async throws + func addProPayment(transactionId: String) async throws } public extension SessionProManagerType { diff --git a/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift new file mode 100644 index 0000000000..5011307775 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProConfig.swift @@ -0,0 +1,38 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct ProConfig { + let rotatingPrivateKey: [UInt8] + let proProof: Network.SessionPro.ProProof + + var libSessionValue: pro_pro_config { + var config: pro_pro_config = pro_pro_config() + config.set(\.rotating_privkey, to: rotatingPrivateKey) + config.proof = proProof.libSessionValue + + return config + } + + // MARK: - Initialization + + init( + rotatingPrivateKey: [UInt8], + proProof: Network.SessionPro.ProProof + ) { + self.rotatingPrivateKey = rotatingPrivateKey + self.proProof = proProof + } + + init(_ libSessionValue: pro_pro_config) { + rotatingPrivateKey = libSessionValue.get(\.rotating_privkey) + proProof = Network.SessionPro.ProProof(libSessionValue.proof) + } + } +} + +extension pro_pro_config: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 73679ed80e..ff600beb0b 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -203,7 +203,7 @@ public struct ProfileEvent: Hashable { isPro: Bool, features: SessionPro.Features, proExpiryUnixTimestampMs: UInt64, - proGenIndexHash: String? + proGenIndexHashHex: String? ) } } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 7b9b316f75..83c091986d 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -270,10 +270,10 @@ public extension Profile { profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: value)) } - if profile.proGenIndexHash != proInfo.proProof.genIndexHash.toHexString() { + if profile.proGenIndexHashHex != proInfo.proProof.genIndexHash.toHexString() { let value: String = proInfo.proProof.genIndexHash.toHexString() - updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: value)) - profileChanges.append(Profile.Columns.proGenIndexHash.set(to: value)) + updatedProfile = updatedProfile.with(proGenIndexHashHex: .set(to: value)) + profileChanges.append(Profile.Columns.proGenIndexHashHex.set(to: value)) } /// If the change count no longer matches then the pro status was updated so we need to emit an event @@ -284,7 +284,7 @@ public extension Profile { isPro: true, features: finalFeatures, proExpiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, - proGenIndexHash: proInfo.proProof.genIndexHash.toHexString() + proGenIndexHashHex: proInfo.proProof.genIndexHash.toHexString() ) ) } @@ -302,9 +302,9 @@ public extension Profile { profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: 0)) } - if profile.proGenIndexHash != nil { - updatedProfile = updatedProfile.with(proGenIndexHash: .set(to: nil)) - profileChanges.append(Profile.Columns.proGenIndexHash.set(to: nil)) + if profile.proGenIndexHashHex != nil { + updatedProfile = updatedProfile.with(proGenIndexHashHex: .set(to: nil)) + profileChanges.append(Profile.Columns.proGenIndexHashHex.set(to: nil)) } /// If the change count no longer matches then the pro status was updated so we need to emit an event @@ -315,7 +315,7 @@ public extension Profile { isPro: false, features: .none, proExpiryUnixTimestampMs: 0, - proGenIndexHash: nil + proGenIndexHashHex: nil ) ) } @@ -442,11 +442,11 @@ public extension Profile { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in - // TODO: [PRO] Need to update the current users pro settings? try cache.updateProfile( displayName: .set(to: updatedProfile.name), displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), + proFeatures: .set(to: updatedProfile.proFeatures), isReuploadProfilePicture: { switch displayPictureUpdate { case .currentUserUpdateTo(_, _, let isReupload): return isReupload diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 8fc93a25a2..12497fca5f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -118,26 +118,8 @@ class MockLibSessionCache: Mock, LibSessionCacheType { func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo], - afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64, [ObservableKey: Any]) throws -> ConfigDump? - ) throws -> [LibSession.MergeResult] { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) - - /// **Note:** Since `afterMerge` is non-escaping (and we don't want to change it to be so for the purposes of mocking - /// in unit test) we just call it directly instead of storing in `untrackedArgs` - let expectation: MockFunction = getExpectation(args: [swarmPublicKey, messages]) - - guard - expectation.closureCallArgs.count == 4, - let sessionId: SessionId = expectation.closureCallArgs[0] as? SessionId, - let variant: ConfigDump.Variant = expectation.closureCallArgs[1] as? ConfigDump.Variant, - let timestamp: Int64 = expectation.closureCallArgs[3] as? Int64, - let oldState: [ObservableKey: Any] = expectation.closureCallArgs[4] as? [ObservableKey: Any] - else { - return try mockThrowing(args: [swarmPublicKey, messages]) - } - - _ = try afterMerge(sessionId, variant, expectation.closureCallArgs[2] as? LibSession.Config, timestamp, oldState) + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws -> [ConfigDump.Variant: Int64] { return try mockThrowing(args: [swarmPublicKey, messages]) } @@ -149,13 +131,6 @@ class MockLibSessionCache: Mock, LibSessionCacheType { try mockThrowingNoReturn(args: [swarmPublicKey, messages], untrackedArgs: [db]) } - func unsafeDirectMergeConfigMessage( - swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] - ) throws { - try mockThrowingNoReturn(args: [swarmPublicKey, messages]) - } - // MARK: - State Access var displayName: String? { mock() } @@ -229,6 +204,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [threadId, threadVariant, openGroupUrlInfo]) } + func proProofMetadata(threadId: String) -> (genIndexHash: String, expiryUnixTimestampMs: Int64)? { + return mock(args: [threadId]) + } + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 89b2bbdb84..3ea77cbed7 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -21,7 +21,7 @@ public extension Network.SessionPro { Task { // FIXME: Make this async/await when the refactored networking is merged do { - let addProProofRequest = try? Network.SessionPro.addProPaymentOrGetProProof( + let addProProofRequest = try? Network.SessionPro.addProPayment( transactionId: "12345678", masterKeyPair: masterKeyPair, rotatingKeyPair: rotatingKeyPair, @@ -32,7 +32,7 @@ public extension Network.SessionPro { .values .first(where: { _ in true })?.1 - let proProofRequest = try? Network.SessionPro.getProProof( + let proProofRequest = try? Network.SessionPro.generateProProof( masterKeyPair: masterKeyPair, rotatingKeyPair: rotatingKeyPair, using: dependencies @@ -64,7 +64,7 @@ public extension Network.SessionPro { } } - static func addProPaymentOrGetProProof( + static func addProPayment( transactionId: String, masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, @@ -109,7 +109,10 @@ public extension Network.SessionPro { ) } - static func getProProof( + /// Generate a pro proof for the provided `rotatingKeyPair` + /// + /// **Note:** If the user doesn't currently have an active Session Pro subscription then this will return an error + static func generateProProof( masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, using dependencies: Dependencies @@ -131,7 +134,7 @@ public extension Network.SessionPro { return try Network.PreparedRequest( request: try Request( method: .post, - endpoint: .getProProof, + endpoint: .generateProProof, body: GenerateProProofRequest( masterPublicKey: masterKeyPair.publicKey, rotatingPublicKey: rotatingKeyPair.publicKey, @@ -165,7 +168,7 @@ public extension Network.SessionPro { return try Network.PreparedRequest( request: try Request( method: .post, - endpoint: .getProStatus, + endpoint: .getProDetails, body: GetProDetailsRequest( masterPublicKey: masterKeyPair.publicKey, timestampMs: timestampMs, diff --git a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift index 0103f06390..ecd21e3481 100644 --- a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift +++ b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift @@ -7,18 +7,18 @@ import Foundation public extension Network.SessionPro { enum Endpoint: EndpointType { case addProPayment - case getProProof + case generateProProof case getProRevocations - case getProStatus + case getProDetails public static var name: String { "SessionPro.Endpoint" } public var path: String { switch self { case .addProPayment: return "add_pro_payment" - case .getProProof: return "get_pro_proof" + case .generateProProof: return "generate_pro_proof" case .getProRevocations: return "get_pro_revocations" - case .getProStatus: return "get_pro_status" + case .getProDetails: return "get_pro_details" } } } diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift index 172c5aa540..8684e4561c 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift @@ -10,15 +10,15 @@ public extension Network.SessionPro { let store: String let platform: String let platformAccount: String - let refundUrl: String + let refundPlatformUrl: String /// Some platforms disallow a refund via their native support channels after some time period /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers /// themselves). If a platform does not have this restriction, this URL is typically the same as - /// the `refund_url`. - let refundAfterPlatformDeadlineUrl: String - + /// the `refundPlatformUrl`. let refundSupportUrl: String + + let refundStatusUrl: String let updateSubscriptionUrl: String let cancelSubscriptionUrl: String @@ -31,9 +31,9 @@ public extension Network.SessionPro { store = libSessionValue.get(\.store) platform = libSessionValue.get(\.platform) platformAccount = libSessionValue.get(\.platform_account) - refundUrl = libSessionValue.get(\.refund_url) - refundAfterPlatformDeadlineUrl = libSessionValue.get(\.refund_after_platform_deadline_url) + refundPlatformUrl = libSessionValue.get(\.refund_platform_url) refundSupportUrl = libSessionValue.get(\.refund_support_url) + refundStatusUrl = libSessionValue.get(\.refund_status_url) updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) } diff --git a/SessionNetworkingKit/Types/NetworkError.swift b/SessionNetworkingKit/Types/NetworkError.swift index 8a91caf417..a438b7c47c 100644 --- a/SessionNetworkingKit/Types/NetworkError.swift +++ b/SessionNetworkingKit/Types/NetworkError.swift @@ -22,6 +22,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case badRequest(error: String, rawData: Data?) case requestFailed(error: String, rawData: Data?) case timeout(error: String, rawData: Data?) + case explicit(String) case suspended case unknown @@ -43,6 +44,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .gatewayTimeout: return "Gateway timeout (NetworkError.gatewayTimeout)." case .badRequest(let error, _), .requestFailed(let error, _): return error case .timeout(let error, _): return "The request timed out with error: \(error) (NetworkError.timeout)." + case .explicit(let error): return error case .suspended: return "Network requests are suspended (NetworkError.suspended)." case .unknown: return "An unknown error occurred (NetworkError.unknown)." } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index b08afda544..26b02f17aa 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -287,7 +287,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension throw MessageError.missingRequiredField("processedMessage") } try dependencies.mutate(cache: .libSession) { cache in - try cache.mergeConfigMessages( + let latestServerTimestampsMs: [ConfigDump.Variant: Int64] = try cache.mergeConfigMessages( swarmPublicKey: swarmPublicKey, messages: [ ConfigMessageReceiveJob.Details.MessageInfo( @@ -296,18 +296,24 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension serverTimestampMs: serverTimestampMs, data: data ) - ], - afterMerge: { sessionId, variant, config, timestampMs, _ in - try updateConfigIfNeeded( - cache: cache, - config: config, - variant: variant, - sessionId: sessionId, - timestampMs: timestampMs - ) - return nil - } + ] ) + + try latestServerTimestampsMs.forEach { variant, timestampMs in + let sessionId: SessionId = SessionId(hex: swarmPublicKey, dumpVariant: variant) + + guard let config: LibSession.Config = cache.config(for: variant, sessionId: sessionId) else { + return + } + + try updateConfigIfNeeded( + cache: cache, + config: config, + variant: variant, + sessionId: sessionId, + timestampMs: timestampMs + ) + } } /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index 86e2af1abc..2ea78cb79e 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -852,6 +852,7 @@ class OnboardingSpec: AsyncSpec { displayName: .set(to: "TestPolledName"), displayPictureUrl: .set(to: "http://filev2.getsession.org/file/1234"), displayPictureEncryptionKey: .set(to: Data([1, 2, 3])), + proFeatures: .set(to: .none), isReuploadProfilePicture: false ) testCacheProfile = cache.profile diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 48ccebae82..febd076dce 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -94,6 +94,10 @@ public extension FeatureStorage { identifier: "proBadgeEverywhere" ) + static let fakeAppleSubscriptionForDev: FeatureConfig = Dependencies.create( + identifier: "fakeAppleSubscriptionForDev" + ) + static let forceMessageFeatureProBadge: FeatureConfig = Dependencies.create( identifier: "forceMessageFeatureProBadge" ) From 5b3c37df4290ec4fb99b2ccfb81aac36859c88f2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 19 Nov 2025 09:14:56 +1100 Subject: [PATCH 22/66] Added the pro revocations request (not used yet) --- Session.xcodeproj/project.pbxproj | 14 ++++- .../DeveloperSettingsProViewModel.swift | 20 +++---- .../LibSession+ConvoInfoVolatile.swift | 3 +- .../SessionPro/SessionProManager.swift | 18 +++--- .../Requests/GetProRevocationsRequest.swift | 33 +++++++++++ .../Requests/GetProRevocationsResponse.swift | 56 +++++++++++++++++++ .../SessionPro/SessionProAPI.swift | 28 ++++++++++ .../SessionPro/Types/RevocationItem.swift | 19 +++++++ 8 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 SessionNetworkingKit/SessionPro/Requests/GetProRevocationsRequest.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift create mode 100644 SessionNetworkingKit/SessionPro/Types/RevocationItem.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cda216c616..4b62534b10 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -647,6 +647,9 @@ FD360EBB2ECAB1500050CAF4 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBA2ECAB1500050CAF4 /* SwiftProtobuf */; }; FD360EBD2ECAB15A0050CAF4 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD360EBC2ECAB15A0050CAF4 /* Lucide */; }; FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */; }; + FD360EC12ECD239B0050CAF4 /* GetProRevocationsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */; }; + FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */; }; + FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; @@ -2044,6 +2047,9 @@ FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityPoller.swift; sourceTree = ""; }; FD3559452CC1FF140088F2A9 /* _034_AddMissingWhisperFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _034_AddMissingWhisperFlag.swift; sourceTree = ""; }; FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProConfig.swift; sourceTree = ""; }; + FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsRequest.swift; sourceTree = ""; }; + FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsResponse.swift; sourceTree = ""; }; + FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevocationItem.swift; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSnodeAPICache.swift; sourceTree = ""; }; @@ -4183,15 +4189,16 @@ isa = PBXGroup; children = ( FD99A3B72EC5882500E59F94 /* AddProPaymentResponseStatus.swift */, + FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */, FD306BD12EB031AB00ADB003 /* PaymentItem.swift */, FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */, FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */, FD306BD72EB033CB00ADB003 /* Plan.swift */, FD0F85762EA83D8F004E0B98 /* ProProof.swift */, - FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */, FD0F85782EA83EAA004E0B98 /* ResponseHeader.swift */, FD0F857A2EA85FA4004E0B98 /* Request+SessionProAPI.swift */, + FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */, FD306BCD2EB02E3400ADB003 /* Signature.swift */, FD0F856C2EA835B6004E0B98 /* Signatures.swift */, FD0F856E2EA83661004E0B98 /* UserTransaction.swift */, @@ -4207,6 +4214,8 @@ FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */, FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */, FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */, + FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */, + FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */, ); path = Requests; sourceTree = ""; @@ -6642,6 +6651,7 @@ FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, + FD360EC12ECD239B0050CAF4 /* GetProRevocationsRequest.swift in Sources */, FDD20C162A09E64A003898FB /* GetExpiriesRequest.swift in Sources */, FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */, FDF848C029405C5A007DCAE5 /* ONSResolveResponse.swift in Sources */, @@ -6661,6 +6671,7 @@ FD6B92F72E77C6D7004463B5 /* Crypto+PushNotification.swift in Sources */, FD6B92B22E77AA03004463B5 /* UpdateTypes.swift in Sources */, FD0F85682EA83385004E0B98 /* SessionProEndpoint.swift in Sources */, + FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */, FD6B92B32E77AA03004463B5 /* Personalization.swift in Sources */, FD6B929B2E77A084004463B5 /* NetworkInfo.swift in Sources */, FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, @@ -6712,6 +6723,7 @@ FD6B92C22E77AA11004463B5 /* SOGSMessage.swift in Sources */, 947D7FD82D509FC900E8E413 /* KeyValueStore+SessionNetwork.swift in Sources */, FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, + FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */, FD6B92F42E77C61A004463B5 /* ServiceInfo.swift in Sources */, FDF848E629405D6E007DCAE5 /* Destination.swift in Sources */, FD6B92A32E77A18B004463B5 /* SnodeAPI.swift in Sources */, diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index d731316446..3ae0b673b5 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -83,7 +83,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case restoreProSubscription case requestRefund case submitPurchaseToProBackend - case refreshProStatus + case refreshProState case removeProFromUserConfig // MARK: - Conformance @@ -107,7 +107,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .restoreProSubscription: return "restoreProSubscription" case .requestRefund: return "requestRefund" case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" - case .refreshProStatus: return "refreshProStatus" + case .refreshProState: return "refreshProState" case .removeProFromUserConfig: return "removeProFromUserConfig" } } @@ -134,7 +134,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough - case .refreshProStatus: result.append(.refreshProStatus); fallthrough + case .refreshProState: result.append(.refreshProState); fallthrough case .removeProFromUserConfig: result.append(.removeProFromUserConfig) } @@ -544,16 +544,16 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } ), SessionCell.Info( - id: .refreshProStatus, - title: "Refresh Pro Status", + id: .refreshProState, + title: "Refresh Pro State", subtitle: """ - Refresh the pro status. + Manually trigger a refresh of the users Pro state. Status: \(currentProStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Refresh"), onTap: { [weak viewModel] in - Task { await viewModel?.refreshProStatus() } + Task { await viewModel?.refreshProState() } } ), SessionCell.Info( @@ -854,9 +854,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } - private func refreshProStatus() async { + private func refreshProState() async { do { - try await dependencies[singleton: .sessionProManager].refreshStatus() + try await dependencies[singleton: .sessionProManager].refreshProState() let status: Network.SessionPro.BackendUserProStatus? = dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus dependencies.notifyAsync( @@ -865,7 +865,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } catch { - Log.error("[DevSettings] Refresh pro status failed: \(error)") + Log.error("[DevSettings] Refresh pro state failed: \(error)") dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.currentProStatus("Error: \(error)", true) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index b2c4a268cc..9905ebedce 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -774,7 +774,7 @@ public extension LibSession { else if convo_info_volatile_it_is_blinded_1to1(convoIterator, &blinded) { result.append( VolatileThreadInfo( - threadId: blinded.get(\.session_id), + threadId: blinded.get(\.blinded_session_id), variant: .contact, changes: [ .markedAsUnread(blinded.unread), @@ -833,3 +833,4 @@ extension convo_info_volatile_1to1: CAccessible & CMutable {} extension convo_info_volatile_community: CAccessible & CMutable {} extension convo_info_volatile_legacy_group: CAccessible & CMutable {} extension convo_info_volatile_group: CAccessible & CMutable {} +extension convo_info_volatile_blinded_1to1: CAccessible & CMutable {} diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index ff27da692d..d6f5af3324 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -253,7 +253,7 @@ public actor SessionProManager: SessionProManagerType { syncState.update(backendUserProStatus: .set(to: response.status)) await self.backendUserProStatusStream.send(response.status) - switch detailsResponse.status { + switch response.status { case .active: try await refreshProProofIfNeeded( accessExpiryTimestampMs: response.expiryTimestampMs, @@ -261,8 +261,8 @@ public actor SessionProManager: SessionProManagerType { status: response.status ) - case .neverBeenPro: await clearProProof() - case .expired: await clearProProof() + case .neverBeenPro: try await clearProProof() + case .expired: try await clearProProof() } } @@ -270,11 +270,11 @@ public actor SessionProManager: SessionProManagerType { public func refreshProProofIfNeeded( accessExpiryTimestampMs: UInt64, autoRenewing: Bool, - status: BackendUserProStatus + status: Network.SessionPro.BackendUserProStatus ) async throws { guard status == .active else { return } - let needsNewProof: Bool = { + let needsNewProof: Bool = await { guard let currentProof: Network.SessionPro.ProProof = await proProofStream.getCurrent() else { return true } @@ -396,7 +396,7 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Internal Functions - private func clearProProof() async { + private func clearProProof() async throws { try await dependencies[singleton: .storage].writeAsync { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile) { _ in @@ -409,9 +409,9 @@ public actor SessionProManager: SessionProManagerType { private func updateExpiryCTAs( accessExpiryTimestampMs: UInt64, autoRenewing: Bool, - status: BackendUserProStatus + status: Network.SessionPro.BackendUserProStatus ) async { - let now: UInt64 = UINt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let now: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) let sevenDaysBeforeExpiry: UInt64 = (accessExpiryTimestampMs - (7 * 60 * 60)) let thirtyDaysAfterExpiry: UInt64 = (accessExpiryTimestampMs + (30 * 60 * 60)) @@ -433,7 +433,7 @@ public actor SessionProManager: SessionProManagerType { } /// Restart the observation (will fetch the correct current states) - await self?.refreshProState() + try? await self?.refreshProState() } } diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsRequest.swift b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsRequest.swift new file mode 100644 index 0000000000..07791bc627 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsRequest.swift @@ -0,0 +1,33 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProRevocationsRequest: Encodable, Equatable { + public let ticket: UInt32 + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_get_pro_revocations_request { + var result: session_pro_backend_get_pro_revocations_request = session_pro_backend_get_pro_revocations_request() + result.version = Network.SessionPro.apiVersion + result.ticket = ticket + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_get_pro_revocations_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_get_pro_revocations_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} diff --git a/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift new file mode 100644 index 0000000000..b20bc63391 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/GetProRevocationsResponse.swift @@ -0,0 +1,56 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct GetProRevocationsResponse: Decodable, Equatable { + public let header: ResponseHeader + public let ticket: UInt32 + public let items: [RevocationItem] + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_get_pro_revocations_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_get_pro_revocations_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.ticket = result.ticket + + if result.items_count > 0 { + self.items = (0.. Network.PreparedRequest { + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProRevocations, + body: GetProRevocationsRequest( + ticket: ticket + ), + using: dependencies + ), + responseType: GetProRevocationsResponse.self, + using: dependencies + ) + } } diff --git a/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift b/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift new file mode 100644 index 0000000000..e619b2acb3 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct RevocationItem: Equatable { + public let genIndexHash: [UInt8] + public let expiryUnixTimestampMs: UInt64 + + init(_ libSessionValue: session_pro_backend_pro_revocation_item) { + genIndexHash = libSessionValue.get(\.gen_index_hash) + expiryUnixTimestampMs = libSessionValue.expiry_unix_ts_ms + } + } +} + +extension session_pro_backend_pro_revocation_item: @retroactive CAccessible {} From 02786a9ad2c1a22d2989d8a8dc427a3d7b0654a5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 19 Nov 2025 10:53:17 +1100 Subject: [PATCH 23/66] Setup the "animatedAvatar" pro feature flag, fixed a couple of bugs --- Session.xcodeproj/project.pbxproj | 8 +- .../Conversations/ConversationViewModel.swift | 4 +- Session/Meta/AppDelegate.swift | 1 + Session/Settings/SettingsViewModel.swift | 2 +- .../Jobs/ReuploadUserDisplayPictureJob.swift | 4 +- .../LibSession+UserProfile.swift | 2 +- .../SessionPro/SessionProManager.swift | 78 ++++++++-- .../Utilities/DisplayPictureManager.swift | 9 +- .../ObservableKey+SessionMessagingKit.swift | 4 +- .../Utilities/Profile+Updating.swift | 145 ++++++++++-------- .../Utilities/OptionSet+Utilities.swift | 19 +++ 11 files changed, 185 insertions(+), 91 deletions(-) create mode 100644 SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 4b62534b10..711a1bf646 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -650,6 +650,7 @@ FD360EC12ECD239B0050CAF4 /* GetProRevocationsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */; }; FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */; }; FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */; }; + FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; @@ -982,7 +983,6 @@ FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A12EBAA6A500E59F94 /* Envelope.swift */; }; FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */; }; FD99A3A62EBAAA1700E59F94 /* DecodedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */; }; - FD99A3AA2EBBF20100E59F94 /* ArraySection+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */; }; FD99A3AC2EBC1B6E00E59F94 /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AB2EBC1B6C00E59F94 /* Server.swift */; }; FD99A3B02EBD4EDD00E59F94 /* FetchablePair.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */; }; FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */; }; @@ -2050,6 +2050,7 @@ FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsRequest.swift; sourceTree = ""; }; FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsResponse.swift; sourceTree = ""; }; FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevocationItem.swift; sourceTree = ""; }; + FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionSet+Utilities.swift"; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSnodeAPICache.swift; sourceTree = ""; }; @@ -2296,7 +2297,6 @@ FD99A3A12EBAA6A500E59F94 /* Envelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Envelope.swift; sourceTree = ""; }; FD99A3A32EBAA6BA00E59F94 /* EnvelopeFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvelopeFlags.swift; sourceTree = ""; }; FD99A3A52EBAAA1400E59F94 /* DecodedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedMessage.swift; sourceTree = ""; }; - FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArraySection+Utilities.swift"; sourceTree = ""; }; FD99A3AB2EBC1B6C00E59F94 /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchablePair.swift; sourceTree = ""; }; FD99A3B12EC3E2EF00E59F94 /* OWSAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSAudioPlayer.swift; sourceTree = ""; }; @@ -4111,7 +4111,6 @@ FD09796527F6B0A800936362 /* Utilities */ = { isa = PBXGroup; children = ( - FD99A3A92EBBF1F400E59F94 /* ArraySection+Utilities.swift */, FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */, 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, @@ -4133,6 +4132,7 @@ FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, FD09797127FAA2F500936362 /* Optional+Utilities.swift */, + FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */, FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */, FD00CDCA2D5317A3006B96D3 /* Scheduler+Utilities.swift */, FDB11A532DCD7A7B00BEF49F /* Task+Utilities.swift */, @@ -6796,6 +6796,7 @@ FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */, FDE755052C9BB4EE002A2623 /* BencodeDecoder.swift in Sources */, + FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */, FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDE754DC2C9BAF8A002A2623 /* CryptoError.swift in Sources */, FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, @@ -6850,7 +6851,6 @@ FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, - FD99A3AA2EBBF20100E59F94 /* ArraySection+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 32b4f735ac..9b459b052a 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1268,8 +1268,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Generate the optimistic data let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages let currentState: State = await self.state - let proFeatures: SessionPro.Features = try { - let userProfileFeatures: SessionPro.Features = .none // TODO: [PRO] Need to add in `proBadge` if enabled + let proFeatures: SessionPro.Features = try await { + let userProfileFeatures: SessionPro.Features = await dependencies[singleton: .sessionProManager].proFeatures let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features( for: (text ?? ""), features: userProfileFeatures diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 6875267409..0e9dc05149 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -81,6 +81,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD LibSession.setupLogger(using: dependencies) dependencies.warm(cache: .libSessionNetwork) dependencies.warm(singleton: .network) + dependencies.warm(singleton: .sessionProManager) // Configure the different targets SNUtilitiesKit.configure( diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index c99f12a77f..30ccfc9438 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -904,7 +904,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - isReupload: false + type: (pendingAttachment.utType.isAnimated ? .animatedImage : .staticImage) ) } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 82bc080587..e2ad7f65e9 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -113,7 +113,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: displayPictureUrl.absoluteString, key: displayPictureEncryptionKey, - isReupload: true + type: .reupload ), using: dependencies ) @@ -172,7 +172,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - isReupload: true + type: .reupload ), using: dependencies ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 7315ca79af..19e2f13748 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -57,7 +57,7 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - isReupload: false + type: .config ) }(), proUpdate: { diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index d6f5af3324..e5a81565b0 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -28,13 +28,13 @@ public enum SessionPro { public actor SessionProManager: SessionProManagerType { private let dependencies: Dependencies nonisolated private let syncState: SessionProManagerSyncState + private var isRefreshingState: Bool = false private var proStatusObservationTask: Task? public var rotatingKeyPair: KeyPair? public var proFeatures: SessionPro.Features = .none nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let decodedProForMessageStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } nonisolated public var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { @@ -55,9 +55,6 @@ public actor SessionProManager: SessionProManagerType { backendUserProStatusStream.stream } nonisolated public var proProof: AsyncStream { proProofStream.stream } - nonisolated public var decodedProForMessage: AsyncStream { - decodedProForMessageStream.stream - } // MARK: - Initialization @@ -69,8 +66,10 @@ public actor SessionProManager: SessionProManagerType { await updateWithLatestFromUserConfig() await startProStatusObservations() - /// Kick off a refresh so we know we have the latest state - try? await refreshProState() + /// Kick off a refresh so we know we have the latest state (if it's the main app) + if dependencies[singleton: .appContext].isMainApp { + try? await refreshProState() + } } } @@ -165,6 +164,23 @@ public actor SessionProManager: SessionProManagerType { } public func updateWithLatestFromUserConfig() async { + if #available(iOS 16.0, *) { + do { try await dependencies.waitUntilInitialised(cache: .libSession) } + catch { return Log.error(.sessionPro, "Failed to wait until libSession initialised: \(error)") } + } + else { + /// iOS 15 doesn't support dependency observation so work around it with a loop + while true { + try? await Task.sleep(for: .milliseconds(500)) + + /// If `libSession` has data we can break + if !dependencies[cache: .libSession].isEmpty { + break + } + } + } + + /// Get the cached pro state from libSession let (proConfig, profile): (SessionPro.ProConfig?, Profile) = dependencies.mutate(cache: .libSession) { ($0.proConfig, $0.profile) } @@ -179,8 +195,20 @@ public actor SessionProManager: SessionProManagerType { /// Update the `syncState` first (just in case an update triggered from the async state results in something accessing the /// sync state) + let proStatus: Network.SessionPro.BackendUserProStatus = { + guard let proof: Network.SessionPro.ProProof = proConfig?.proProof else { + return .neverBeenPro + } + + let proofIsActive: Bool = proProofIsActive( + for: proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + return (proofIsActive ? .active : .expired) + }() syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), + backendUserProStatus: .set(to: proStatus), proProof: .set(to: proConfig?.proProof), proFeatures: .set(to: profile.proFeatures) ) @@ -189,6 +217,7 @@ public actor SessionProManager: SessionProManagerType { self.rotatingKeyPair = rotatingKeyPair self.proFeatures = profile.proFeatures await self.proProofStream.send(proConfig?.proProof) + await self.backendUserProStatusStream.send(proStatus) } @discardableResult @MainActor public func showSessionProCTAIfNeeded( @@ -233,6 +262,12 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Pro State Management public func refreshProState() async throws { + /// No point refreshing the state if there is a refresh in progress + guard !isRefreshingState else { return } + + isRefreshingState = true + + // FIXME: Await network connectivity when the refactored networking is merged let request = try? Network.SessionPro.getProDetails( masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), using: dependencies @@ -265,6 +300,7 @@ public actor SessionProManager: SessionProManagerType { case .expired: try await clearProProof() } + isRefreshingState = false } public func refreshProProofIfNeeded( @@ -281,7 +317,7 @@ public actor SessionProManager: SessionProManagerType { let sixtyMinutesBeforeAccessExpiry: UInt64 = (accessExpiryTimestampMs - (60 * 60)) let sixtyMinutesBeforeProofExpiry: UInt64 = (currentProof.expiryUnixTimestampMs - (60 * 60)) - let now: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let now: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() return ( sixtyMinutesBeforeProofExpiry < now && @@ -289,6 +325,10 @@ public actor SessionProManager: SessionProManagerType { autoRenewing ) }() + + /// Only generate a new proof if we need one + guard needsNewProof else { return } + let rotatingKeyPair: KeyPair = try ( self.rotatingKeyPair ?? dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) @@ -307,7 +347,7 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") - Log.error(.sessionPro, "Failed to generate new pro proot due to error(s): \(errorString)") + Log.error(.sessionPro, "Failed to generate new pro proof due to error(s): \(errorString)") throw NetworkError.explicit(errorString) } @@ -328,14 +368,19 @@ public actor SessionProManager: SessionProManagerType { /// Send the proof and status events on the streams /// /// **Note:** We can assume that the users status is `active` since they just successfully generated a pro proof + let proofIsActive: Bool = proProofIsActive( + for: response.proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - backendUserProStatus: .set(to: .active), + backendUserProStatus: .set(to: proStatus), proProof: .set(to: response.proof) ) self.rotatingKeyPair = rotatingKeyPair await self.proProofStream.send(response.proof) - await self.backendUserProStatusStream.send(.active) + await self.backendUserProStatusStream.send(proStatus) } public func addProPayment(transactionId: String) async throws { @@ -380,14 +425,19 @@ public actor SessionProManager: SessionProManagerType { /// /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and /// received a pro proof + let proofIsActive: Bool = proProofIsActive( + for: response.proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - backendUserProStatus: .set(to: .active), + backendUserProStatus: .set(to: proStatus), proProof: .set(to: response.proof) ) self.rotatingKeyPair = rotatingKeyPair await self.proProofStream.send(response.proof) - await self.backendUserProStatusStream.send(.active) + await self.backendUserProStatusStream.send(proStatus) /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other /// edge-cases since it's the main driver to the Pro state) @@ -411,7 +461,7 @@ public actor SessionProManager: SessionProManagerType { autoRenewing: Bool, status: Network.SessionPro.BackendUserProStatus ) async { - let now: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) + let now: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let sevenDaysBeforeExpiry: UInt64 = (accessExpiryTimestampMs - (7 * 60 * 60)) let thirtyDaysAfterExpiry: UInt64 = (accessExpiryTimestampMs + (30 * 60 * 60)) @@ -444,8 +494,6 @@ public actor SessionProManager: SessionProManagerType { /// the "real" status guard dependencies[feature: .sessionProEnabled] else { continue } guard let status: Network.SessionPro.BackendUserProStatus = status else { - self?.syncState.update(backendUserProStatus: .set(to: nil)) - await self?.backendUserProStatusStream.send(nil) try? await self?.refreshProState() continue } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index a48e4fa85c..d02b312716 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -36,7 +36,7 @@ public class DisplayPictureManager { case contactUpdateTo(url: String, key: Data) case currentUserRemove - case currentUserUpdateTo(url: String, key: Data, isReupload: Bool) + case currentUserUpdateTo(url: String, key: Data, type: UpdateType) case groupRemove case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) @@ -60,6 +60,13 @@ public class DisplayPictureManager { } } + public enum UpdateType { + case staticImage + case animatedImage + case reupload + case config + } + public static let maxBytes: UInt = (5 * 1000 * 1000) public static let maxDimension: CGFloat = 600 public static var encryptionKeySize: Int { LibSession.attachmentEncryptionKeySize } diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index ff600beb0b..51ff94989b 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -202,8 +202,8 @@ public struct ProfileEvent: Hashable { case proStatus( isPro: Bool, features: SessionPro.Features, - proExpiryUnixTimestampMs: UInt64, - proGenIndexHashHex: String? + expiryUnixTimestampMs: UInt64, + genIndexHashHex: String? ) } } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 83c091986d..55f8677de7 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -77,6 +77,17 @@ public extension Profile { } } + private struct ProfileProState: Equatable { + let features: SessionPro.Features + let expiryUnixTimestampMs: UInt64 + let genIndexHashHex: String? + + var isPro: Bool { + expiryUnixTimestampMs > 0 && + genIndexHashHex != nil + } + } + static func isTooLong(profileName: String) -> Bool { /// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer. /// When the string is exactly 100 bytes String.utf8CString.count will be 101. @@ -158,11 +169,17 @@ public extension Profile { ) throws { let isCurrentUser = currentUserSessionIds.contains(publicKey) let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) + let proState: ProfileProState = ProfileProState( + features: profile.proFeatures, + expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs, + genIndexHashHex: profile.proGenIndexHashHex + ) let updateStatus: UpdateStatus = UpdateStatus( updateTimestamp: profileUpdateTimestamp, cachedProfile: profile ) var updatedProfile: Profile = profile + var updatedProState: ProfileProState = proState var profileChanges: [ConfigColumnAssignment] = [] /// We should only update profile info controled by other users if `updateStatus` is `shouldUpdate` @@ -248,6 +265,25 @@ public extension Profile { default: break } + /// Update the pro state based on whether the updated display picture is animated or not + if isCurrentUser, case .currentUserUpdateTo(_, _, let type) = displayPictureUpdate { + switch type { + case .staticImage: + updatedProState = ProfileProState( + features: updatedProState.features.removing(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + case .animatedImage: + updatedProState = ProfileProState( + features: updatedProState.features.inserting(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + case .reupload, .config: break /// Don't modify the current state + } + } + /// Session Pro Information (if it's not the current user) switch (proUpdate, isCurrentUser) { case (.none, _): break @@ -256,74 +292,57 @@ public extension Profile { switch proInfo.status { case .valid: - let originalChangeCount: Int = profileChanges.count - let finalFeatures: SessionPro.Features = proInfo.features.profileOnlyFeatures - - if profile.proFeatures != finalFeatures { - updatedProfile = updatedProfile.with(proFeatures: .set(to: finalFeatures)) - profileChanges.append(Profile.Columns.proFeatures.set(to: finalFeatures.rawValue)) - } - - if profile.proExpiryUnixTimestampMs != proInfo.proProof.expiryUnixTimestampMs { - let value: UInt64 = proInfo.proProof.expiryUnixTimestampMs - updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: value)) - profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: value)) - } - - if profile.proGenIndexHashHex != proInfo.proProof.genIndexHash.toHexString() { - let value: String = proInfo.proProof.genIndexHash.toHexString() - updatedProfile = updatedProfile.with(proGenIndexHashHex: .set(to: value)) - profileChanges.append(Profile.Columns.proGenIndexHashHex.set(to: value)) - } - - /// If the change count no longer matches then the pro status was updated so we need to emit an event - if profileChanges.count != originalChangeCount { - db.addProfileEvent( - id: publicKey, - change: .proStatus( - isPro: true, - features: finalFeatures, - proExpiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, - proGenIndexHashHex: proInfo.proProof.genIndexHash.toHexString() - ) - ) - } + updatedProState = ProfileProState( + features: proInfo.features.profileOnlyFeatures, + expiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, + genIndexHashHex: proInfo.proProof.genIndexHash.toHexString() + ) default: - let originalChangeCount: Int = profileChanges.count - - if profile.proFeatures != .none { - updatedProfile = updatedProfile.with(proFeatures: .set(to: .none)) - profileChanges.append(Profile.Columns.proFeatures.set(to: .none)) - } - - if profile.proExpiryUnixTimestampMs > 0 { - updatedProfile = updatedProfile.with(proExpiryUnixTimestampMs: .set(to: 0)) - profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs.set(to: 0)) - } - - if profile.proGenIndexHashHex != nil { - updatedProfile = updatedProfile.with(proGenIndexHashHex: .set(to: nil)) - profileChanges.append(Profile.Columns.proGenIndexHashHex.set(to: nil)) - } - - /// If the change count no longer matches then the pro status was updated so we need to emit an event - if profileChanges.count != originalChangeCount { - db.addProfileEvent( - id: publicKey, - change: .proStatus( - isPro: false, - features: .none, - proExpiryUnixTimestampMs: 0, - proGenIndexHashHex: nil - ) - ) - } + updatedProState = ProfileProState( + features: .none, + expiryUnixTimestampMs: 0, + genIndexHashHex: nil + ) } /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } + + /// If the pro state no longer matches then we need to emit an event + if updatedProState != proState { + if updatedProState.features != proState.features { + updatedProfile = updatedProfile.with(proFeatures: .set(to: updatedProState.features)) + profileChanges.append(Profile.Columns.proFeatures.set(to: updatedProState.features.rawValue)) + } + + if updatedProState.expiryUnixTimestampMs != proState.expiryUnixTimestampMs { + updatedProfile = updatedProfile.with( + proExpiryUnixTimestampMs: .set(to: updatedProState.expiryUnixTimestampMs) + ) + profileChanges.append(Profile.Columns.proExpiryUnixTimestampMs + .set(to: updatedProState.expiryUnixTimestampMs)) + } + + if updatedProState.genIndexHashHex != proState.genIndexHashHex { + updatedProfile = updatedProfile.with( + proGenIndexHashHex: .set(to: updatedProState.genIndexHashHex) + ) + profileChanges.append(Profile.Columns.proGenIndexHashHex + .set(to: updatedProState.genIndexHashHex)) + } + + db.addProfileEvent( + id: publicKey, + change: .proStatus( + isPro: updatedProState.isPro, + features: updatedProState.features, + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + ) + } } /// Nickname - this is controlled by the current user so should always be used @@ -446,10 +465,10 @@ public extension Profile { displayName: .set(to: updatedProfile.name), displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), - proFeatures: .set(to: updatedProfile.proFeatures), + proFeatures: .set(to: updatedProState.features), isReuploadProfilePicture: { switch displayPictureUpdate { - case .currentUserUpdateTo(_, _, let isReupload): return isReupload + case .currentUserUpdateTo(_, _, let type): return (type == .reupload) default: return false } }() diff --git a/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift b/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift new file mode 100644 index 0000000000..42c0a020c1 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/OptionSet+Utilities.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension OptionSet { + func inserting(_ other: Element?) -> Self { + guard let other: Element = other else { return self } + + var result: Self = self + result.insert(other) + return result + } + + func removing(_ other: Element) -> Self { + var result: Self = self + result.remove(other) + return result + } +} From 21e514b7a5adec9cb81db7189c3aabcae5d81956 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 20 Nov 2025 10:46:09 +1100 Subject: [PATCH 24/66] Updated the conversation screen to correctly update title pro badge --- .../Conversations/ConversationViewModel.swift | 10 ++++++-- .../ConversationTitleView.swift | 4 +-- .../SessionPro/SessionProManager.swift | 4 +-- .../Utilities/Profile+Updating.swift | 25 ++++++++++++++++++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 9b459b052a..3ed3b86186 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -341,6 +341,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold itemCache: [:], titleViewModel: ConversationTitleViewModel( threadViewModel: threadViewModel, + profileCache: [:], using: dependencies ), threadViewModel: threadViewModel, @@ -1181,6 +1182,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold itemCache: itemCache, titleViewModel: ConversationTitleViewModel( threadViewModel: threadViewModel, + profileCache: profileCache, using: dependencies ), threadViewModel: threadViewModel, @@ -1872,12 +1874,16 @@ private extension ObservedEvent { } private extension ConversationTitleViewModel { - init(threadViewModel: SessionThreadViewModel, using dependencies: Dependencies) { + init( + threadViewModel: SessionThreadViewModel, + profileCache: [String: Profile], + using dependencies: Dependencies + ) { self.threadVariant = threadViewModel.threadVariant self.displayName = threadViewModel.displayName self.isNoteToSelf = threadViewModel.threadIsNoteToSelf self.isMessageRequest = (threadViewModel.threadIsMessageRequest == true) - self.isSessionPro = dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro + self.showProBadge = (profileCache[threadViewModel.threadId]?.proFeatures.contains(.proBadge) == true) self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadViewModel.threadMutedUntilTimestamp ?? 0)) self.onlyNotifyForMentions = (threadViewModel.threadOnlyNotifyForMentions == true) self.userCount = threadViewModel.userCount diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index cc80b2badc..f6c1aca6d2 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -12,7 +12,7 @@ struct ConversationTitleViewModel: Sendable, Equatable { let displayName: String let isNoteToSelf: Bool let isMessageRequest: Bool - let isSessionPro: Bool + let showProBadge: Bool let isMuted: Bool let onlyNotifyForMentions: Bool let userCount: Int? @@ -126,7 +126,7 @@ final class ConversationTitleView: UIView { self.titleLabel.text = viewModel.displayName self.titleLabel.accessibilityLabel = viewModel.displayName self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) - self.titleLabel.isProBadgeHidden = !viewModel.isSessionPro + self.titleLabel.isProBadgeHidden = !viewModel.showProBadge self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index e5a81565b0..e4c0a80106 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -30,7 +30,7 @@ public actor SessionProManager: SessionProManagerType { nonisolated private let syncState: SessionProManagerSyncState private var isRefreshingState: Bool = false private var proStatusObservationTask: Task? - public var rotatingKeyPair: KeyPair? + private var rotatingKeyPair: KeyPair? public var proFeatures: SessionPro.Features = .none nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) @@ -557,7 +557,7 @@ private final class SessionProManagerSyncState { // MARK: - SessionProManagerType public protocol SessionProManagerType: SessionProUIManagerType { - var rotatingKeyPair: KeyPair? { get } + var proFeatures: SessionPro.Features { get } nonisolated var characterLimit: Int { get } nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 55f8677de7..f1d2bb3b03 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -3,6 +3,7 @@ import Foundation import Combine import GRDB +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Log.Category @@ -101,7 +102,7 @@ public extension Profile { static func updateLocal( displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, - proUpdate: TargetUserUpdate = .none, + proFeatures: SessionPro.Features? = nil, using dependencies: Dependencies ) async throws { /// Perform any non-database related changes for the update @@ -136,6 +137,28 @@ public extension Profile { do { let userSessionId: SessionId = dependencies[cache: .general].sessionId let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + let proUpdate: TargetUserUpdate = await { + let maybeProof: Network.SessionPro.ProProof? = await dependencies[singleton: .sessionProManager] + .proProof + .first(defaultValue: nil) + + guard + let targetFeatures: SessionPro.Features = proFeatures, + let proof: Network.SessionPro.ProProof = maybeProof, + dependencies[singleton: .sessionProManager].proProofIsActive( + for: proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + else { return .none } + + return .currentUserUpdate( + SessionPro.DecodedProForMessage( + status: .valid, + proProof: proof, + features: targetFeatures + ) + ) + }() try await dependencies[singleton: .storage].writeAsync { db in try Profile.updateIfNeeded( From ceb6405bb6a1269afe97746a0c37e2bd7e114437 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 25 Nov 2025 14:33:59 +1100 Subject: [PATCH 25/66] Further progress wiring up Session Pro to Pro Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added some convenience code to clean up the DSL of consuming events • Cleaned up some profile name logic • Updated the code to use the `displayNameRetriever` approach where possible instead of resorting to a database query on the main thread • Updated the ObservationBuilder to accept a stream (so we can observe events directly from a stream) • Wired a bunch of the Session Pro Settings UI to the proper pro state • Fixed a bug where the toggle wouldn't be tinted the right colour in SwiftUI screens • Fixed a bug where mentions wouldn't work correctly • Fixed a bug where mentions could crash when backspacing • Fixed a bug where determining whether a string was RTL could cause lag • Fixed a bug where the attachment and voice message buttons weren't visible in message requests • Fixed a bug where the scroll to bottom button wouldn't work correctly --- Session.xcodeproj/project.pbxproj | 28 +- .../Call Management/SessionCallManager.swift | 6 +- .../Context Menu/ContextMenuVC+Action.swift | 2 +- .../ConversationVC+Interaction.swift | 238 ++---- Session/Conversations/ConversationVC.swift | 79 +- .../Conversations/ConversationViewModel.swift | 236 +++--- .../Message Cells/CallMessageCell.swift | 1 + .../Message Cells/DateHeaderCell.swift | 1 + .../Message Cells/InfoMessageCell.swift | 1 + .../Message Cells/MessageCell.swift | 2 + .../Message Cells/TypingIndicatorCell.swift | 1 + .../Message Cells/UnreadMarkerCell.swift | 1 + .../Message Cells/VisibleMessageCell.swift | 62 +- .../Settings/ThreadSettingsViewModel.swift | 2 +- .../Views & Modals/ReactionListSheet.swift | 2 +- Session/Home/HomeViewModel.swift | 68 +- .../MessageRequestsViewModel.swift | 57 +- .../MediaPageViewController.swift | 3 +- .../MessageInfoScreen.swift | 119 +-- Session/Meta/Session+SNUIKit.swift | 5 +- .../DeveloperSettingsProViewModel.swift | 62 +- .../DeveloperSettingsViewModel.swift | 6 +- Session/Settings/NukeDataModal.swift | 8 +- .../SessionProSettingsViewModel.swift | 699 ++++++++++-------- Session/Settings/SettingsViewModel.swift | 38 +- .../Views/ThemeMessagePreviewView.swift | 2 + .../MentionUtilities+DisplayName.swift | 22 - .../UIContextualAction+Utilities.swift | 4 +- .../Database/Models/Contact.swift | 6 +- .../Database/Models/GroupMember.swift | 12 +- .../Database/Models/Profile.swift | 71 +- .../Database/Models/SessionThread.swift | 13 +- .../Jobs/GroupInviteMemberJob.swift | 6 +- .../Jobs/GroupPromoteMemberJob.swift | 6 +- .../Config Handling/LibSession+Shared.swift | 2 +- .../LibSession+UserProfile.swift | 8 +- .../LibSession+SessionMessagingKit.swift | 2 + .../VisibleMessage+LinkPreview.swift | 2 +- .../MessageReceiver+Groups.swift | 12 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../MessageSender+Groups.swift | 8 +- .../NotificationsManagerType.swift | 24 +- .../SessionPro/SessionProManager.swift | 132 +++- .../SessionProDecodedProForMessage.swift | 6 +- ...us.swift => SessionProDecodedStatus.swift} | 2 +- .../SessionPro/Types/SessionProPlan.swift | 138 ++++ .../Shared Models/MessageViewModel.swift | 330 +++++---- .../SessionThreadViewModel.swift | 66 +- .../Utilities/ImageLoading+Convenience.swift | 20 - ...ionSelectionView+SessionMessagingKit.swift | 82 +- .../ObservableKey+SessionMessagingKit.swift | 18 +- .../Utilities/Preferences.swift | 3 - .../Utilities/Profile+Updating.swift | 38 +- .../ProfilePictureView+Convenience.swift | 8 +- .../SessionPro/Types/PaymentItem.swift | 12 +- .../SessionPro/Types/PaymentProvider.swift | 10 +- .../Types/PaymentProviderMetadata.swift | 2 +- .../SessionPro/Types/PaymentStatus.swift | 2 +- .../SessionPro/Types/Plan.swift | 2 +- .../SessionPro/Types/ProProof.swift | 2 +- .../SessionPro/Types/UserTransaction.swift | 6 +- .../NotificationServiceExtension.swift | 20 +- .../ShareNavController.swift | 5 +- .../Components/Input View/InputView.swift | 53 +- SessionUIKit/Components/LinkPreviewView.swift | 4 +- SessionUIKit/Components/QuoteView.swift | 6 +- .../Components/SwiftUI/AnimatedToggle.swift | 1 + .../Components/SwiftUI/ProCTAModal.swift | 8 +- .../SwiftUI/QuoteView_SwiftUI.swift | 278 ++++--- .../Components/SwiftUI/UserProfileModal.swift | 24 +- .../SessionProPaymentScreen+Models.swift | 89 ++- SessionUIKit/Style Guide/Themes/Theme.swift | 2 +- SessionUIKit/Types/Localization.swift | 43 +- .../Types/SessionProUIManagerType.swift | 16 +- SessionUIKit/Utilities/MentionUtilities.swift | 10 +- .../Database/Types/PagedData.swift | 4 +- SessionUtilitiesKit/General/Feature.swift | 11 +- .../General/ReusableView.swift | 17 + .../Observations/ObservableKey.swift | 56 +- .../Observations/ObservationBuilder.swift | 37 +- .../Observations/ObservationUtilities.swift | 90 +++ .../AttachmentApprovalViewController.swift | 6 +- 82 files changed, 1944 insertions(+), 1644 deletions(-) rename SessionMessagingKit/SessionPro/Types/{SessionProStatus.swift => SessionProDecodedStatus.swift} (96%) create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProPlan.swift rename {Session => SessionMessagingKit}/Utilities/ImageLoading+Convenience.swift (91%) create mode 100644 SessionUtilitiesKit/General/ReusableView.swift create mode 100644 SessionUtilitiesKit/Observations/ObservationUtilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a067a10370..5bb35c39b6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -671,6 +671,9 @@ FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */; }; FD360ED12ECFB8AC0050CAF4 /* SessionProExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */; }; FD360ED32ECFBC890050CAF4 /* SessionProClientPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */; }; + FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; + FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */; }; + FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; }; FD3765DF2AD8F03100DC1489 /* MockSnodeAPICache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */; }; @@ -1043,7 +1046,7 @@ FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */; }; FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProFeatures.swift */; }; FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */; }; - FDAA36D02EB485F20040603E /* SessionProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */; }; + FDAA36D02EB485F20040603E /* SessionProDecodedStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */; }; FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; FDAB8A852EB2BC37000A6C65 /* MentionSelectionView+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */; }; FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; @@ -1066,7 +1069,6 @@ FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */; }; FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */; }; FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */; }; - FDB3DA8D2E24881B00148F8D /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */; }; FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; FDB5DAC12A9443A5002C8721 /* MessageSender+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */; }; FDB5DAC72A9447E7002C8721 /* _036_GroupsRebuildChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC62A9447E7002C8721 /* _036_GroupsRebuildChanges.swift */; }; @@ -1148,7 +1150,6 @@ FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; FDE5218E2E03A06B00061B8E /* AttachmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5218D2E03A06700061B8E /* AttachmentManager.swift */; }; FDE521942E050B1100061B8E /* DismissCallbackAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */; }; - FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219B2E08E76600061B8E /* SessionAsyncImage.swift */; }; FDE521A02E0D230000061B8E /* ObservationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */; }; FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */; }; @@ -2119,6 +2120,8 @@ FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProLoadingState.swift; sourceTree = ""; }; FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExpiry.swift; sourceTree = ""; }; FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProClientPlatform.swift; sourceTree = ""; }; + FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationUtilities.swift; sourceTree = ""; }; + FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPlan.swift; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; FD3765DE2AD8F03100DC1489 /* MockSnodeAPICache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSnodeAPICache.swift; sourceTree = ""; }; @@ -2389,7 +2392,7 @@ FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatureStatus.swift; sourceTree = ""; }; FDAA36C92EB476060040603E /* SessionProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatures.swift; sourceTree = ""; }; FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedProForMessage.swift; sourceTree = ""; }; - FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProStatus.swift; sourceTree = ""; }; + FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedStatus.swift; sourceTree = ""; }; FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionSelectionView+SessionMessagingKit.swift"; sourceTree = ""; }; FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadUpdateInfo.swift; sourceTree = ""; }; @@ -2409,7 +2412,6 @@ FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCancellation.swift; sourceTree = ""; }; FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAsyncImage.swift; sourceTree = ""; }; FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVURLAsset+Utilities.swift"; sourceTree = ""; }; - FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageLoading+Convenience.swift"; sourceTree = ""; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageSender+Groups.swift"; sourceTree = ""; }; FDB5DAC62A9447E7002C8721 /* _036_GroupsRebuildChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _036_GroupsRebuildChanges.swift; sourceTree = ""; }; @@ -2949,8 +2951,6 @@ FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, - FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, B8D84EA225DF745A005A043E /* LinkPreview+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, @@ -3956,6 +3956,7 @@ FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */, + FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */, @@ -4670,6 +4671,7 @@ FD52CB622E13B61700A4DA70 /* ObservableKey.swift */, FD42ECD52E3308AC002D03EA /* ObservableKey+SessionUtilitiesKit.swift */, FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */, + FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */, FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */, ); path = Observations; @@ -5316,12 +5318,13 @@ FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */, FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, + FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, FDAA36C92EB476060040603E /* SessionProFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, - FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, - FDAA36CF2EB485EF0040603E /* SessionProStatus.swift */, + FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, + FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, ); path = Types; sourceTree = ""; @@ -7100,6 +7103,7 @@ FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, + FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, FD09796B27F6C67500936362 /* Failable.swift in Sources */, @@ -7279,6 +7283,7 @@ 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, 94B6BAF62E30A88800E718BB /* SessionProManager.swift in Sources */, FDB5DADE2A95D847002C8721 /* GroupUpdatePromoteMessage.swift in Sources */, + FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, @@ -7339,6 +7344,7 @@ FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */, FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */, + FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */, FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDD23AE92E458E020057E853 /* _003_SUK_YDBToGRDBMigration.swift in Sources */, @@ -7383,7 +7389,7 @@ FDB5DADA2A95D839002C8721 /* GroupUpdateInfoChangeMessage.swift in Sources */, FDF71EA32B072C2800A8D6B5 /* LibSessionMessage.swift in Sources */, FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */, - FDAA36D02EB485F20040603E /* SessionProStatus.swift in Sources */, + FDAA36D02EB485F20040603E /* SessionProDecodedStatus.swift in Sources */, FDD23AED2E4590A10057E853 /* _041_RenameTableSettingToKeyValueStore.swift in Sources */, FDE754FE2C9BB0D0002A2623 /* Threading+SessionMessagingKit.swift in Sources */, FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */, @@ -7461,7 +7467,6 @@ 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */, FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */, 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, - FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */, FD71165228E410BE00B47552 /* SessionTableSection.swift in Sources */, C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, 9422568B2C23F8C800C0FDBF /* LoadAccountScreen.swift in Sources */, @@ -7487,7 +7492,6 @@ FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, FD37EA0328A9FDCC003AE748 /* HelpViewModel.swift in Sources */, FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, - FDB3DA8D2E24881B00148F8D /* ImageLoading+Convenience.swift in Sources */, 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */, 7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */, FD71162228D983ED00B47552 /* QRCodeScanningViewController.swift in Sources */, diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 7554447920..f16d95940a 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -209,11 +209,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { let call: SessionCall = dependencies[singleton: .storage].read({ [dependencies] db in SessionCall( for: caller, - contactName: Profile.displayName( - db, - id: caller, - threadVariant: .contact - ), + contactName: Profile.displayName(db, id: caller), uuid: uuid, mode: mode, using: dependencies diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index cfb68f2556..2b1ebcc94a 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -306,7 +306,7 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) - func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + @MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ea91c4f0c3..5b4c5625ec 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -581,29 +581,25 @@ extension ConversationVC: guard !didShowCTAModal else { return } - self.hideInputAccessoryView() let numberOfCharactersLeft: Int = viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) - let limit: Int = (viewModel.isCurrentUserSessionPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( - title: ( - (numberOfCharactersLeft >= 0) ? - "modalMessageCharacterDisplayTitle".localized() : - "modalMessageCharacterTooLongTitle".localized() + title: (numberOfCharactersLeft >= 0 ? + "modalMessageCharacterDisplayTitle".localized() : + "modalMessageCharacterTooLongTitle".localized() ), body: .text( - ( - (numberOfCharactersLeft >= 0) ? - "modalMessageCharacterDisplayDescription" - .putNumber(numberOfCharactersLeft) - .put(key: "limit", value: limit) - .localized() : - "modalMessageCharacterTooLongDescription" - .put(key: "limit", value: limit) - .localized() + (numberOfCharactersLeft >= 0 ? + "modalMessageCharacterDisplayDescription" + .putNumber(numberOfCharactersLeft) + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) + .localized() : + "modalMessageCharacterTooLongDescription" + .put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit) + .localized() ), scrollMode: .never ), @@ -691,8 +687,7 @@ extension ConversationVC: guard viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines) ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(viewModel.isCurrentUserSessionPro) - return + return showModalForMessagesExceedingCharacterLimit() } sendMessage( @@ -702,7 +697,7 @@ extension ConversationVC: ) } - @MainActor func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { + @MainActor func showModalForMessagesExceedingCharacterLimit() { let didShowCTAModal: Bool = viewModel.dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .longerMessages, afterClosed: { [weak self] in @@ -885,11 +880,16 @@ extension ConversationVC: } // If there is a Quote the insert it now - if let interactionId: Int64 = insertedInteraction.id, let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel { + if + let interactionId: Int64 = insertedInteraction.id, + let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel, + let quotedAuthorId: String = quoteViewModel.quotedInfo?.authorId, + let quotedTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs + { try Quote( interactionId: interactionId, - authorId: quoteViewModel.authorId, - timestampMs: quoteViewModel.timestampMs + authorId: quotedAuthorId, + timestampMs: quotedTimestampMs ).insert(db) } @@ -964,7 +964,6 @@ extension ConversationVC: guard !viewIsAppearing else { return } let newText: String = (inputTextView.text ?? "") - let currentUserSessionIds: Set = (viewModel.threadData.currentUserSessionIds ?? []) if !newText.isEmpty { Task { [state = viewModel.state, dependencies = viewModel.dependencies] in @@ -1045,10 +1044,14 @@ extension ConversationVC: guard newText.count > 1 else { return false } /// Only a single char guard currentStartIndex != nil || lastCharacterIsMentionChar else { return false } /// No mention char - let mentionCharIndex: String.Index = ( - currentStartIndex ?? - newText.index(before: lastCharacterIndex) - ) + let mentionCharIndex: String.Index = { + guard + let currentStartIndex: String.Index = currentStartIndex, + lastCharacterIndex >= currentStartIndex + else { return newText.index(before: lastCharacterIndex) } + + return currentStartIndex + }() return (String(newText[mentionCharIndex]) == MentionSelectionView.ViewModel.mentionChar) }() let isValidMention: Bool = ( @@ -1073,6 +1076,10 @@ extension ConversationVC: let mentions: [MentionSelectionView.ViewModel] = ((try? await self.viewModel.mentions(for: query)) ?? []) await MainActor.run { + if lastCharacterIsMentionChar { + currentMentionStartIndex = lastCharacterIndex + } + snInputView.showMentionsUI(for: mentions) } } @@ -1160,7 +1167,7 @@ extension ConversationVC: messageInfo.state == .permissionDeniedMicrophone else { let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( - caller: cellViewModel.authorName, + caller: cellViewModel.authorName(), presentingViewController: self, using: viewModel.dependencies ) @@ -1251,7 +1258,7 @@ extension ConversationVC: // If it's an incoming media message and the thread isn't trusted then show the placeholder view if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { let message: ThemedAttributedString = "attachmentsAutoDownloadModalDescription" - .put(key: "conversation_name", value: cellViewModel.authorName) + .put(key: "conversation_name", value: cellViewModel.authorName()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -1462,22 +1469,15 @@ extension ConversationVC: // If the message contains both links and a quote, and the user tapped on the quote; OR the // message only contained a quote, then scroll to the quote case (true, true, _, .some(let quoteViewModel), _), (false, _, _, .some(let quoteViewModel), _): - let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in - try Interaction - .filter(id: quoteViewModel.quotedInteractionId) - .select(.timestampMs) - .asRequest(of: Int64.self) - .fetchOne(db) - } - - guard let timestampMs: Int64 = maybeTimestampMs else { - return - } + guard + let quotedInteractionId: Int64 = quoteViewModel.quotedInfo?.interactionId, + let quotedInteractionTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs + else { return } self.scrollToInteractionIfNeeded( with: Interaction.TimestampInfo( - id: quoteViewModel.quotedInteractionId, - timestampMs: timestampMs + id: quotedInteractionId, + timestampMs: quotedInteractionTimestampMs ), focusBehaviour: .highlight, originalIndexPath: self.tableView.indexPath(for: cell) @@ -1551,105 +1551,38 @@ extension ConversationVC: } func showUserProfileModal(for cellViewModel: MessageViewModel) { - guard viewModel.state.threadViewModel.threadCanWrite == true else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: cellViewModel.authorId)) != .blinded25 else { return } - - let dependencies: Dependencies = viewModel.dependencies - - let (info, _) = ProfilePictureView.Info.generateInfoFrom( - size: .hero, - publicKey: cellViewModel.authorId, - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: cellViewModel.profile, - using: dependencies - ) - - guard let profileInfo: ProfilePictureView.Info = info else { return } - - let (sessionId, blindedId): (String?, String?) = { - guard - (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15, - let openGroupServer: String = viewModel.state.threadViewModel.openGroupServer, - let openGroupPublicKey: String = viewModel.state.threadViewModel.openGroupPublicKey - else { - return (cellViewModel.authorId, nil) - } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in - try BlindedIdLookup.fetchOrCreate( - db, - blindedId: cellViewModel.authorId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: dependencies - ) - } - return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) - }() - let (displayName, contactDisplayName): (String?, String?) = { - guard let sessionId: String = sessionId else { - return (cellViewModel.authorNameSuppressedId, nil) - } - guard !viewModel.state.currentUserSessionIds.contains(sessionId) else { - return ("you".localized(), "you".localized()) - } - - return ( - ( - viewModel.state.profileCache[sessionId]?.displayName(for: .contact) ?? - cellViewModel.authorNameSuppressedId - ), - viewModel.state.profileCache[sessionId]?.displayName(for: .contact, ignoringNickname: true) + guard + viewModel.state.threadViewModel.threadCanWrite == true, + let info: UserProfileModal.Info = cellViewModel.createUserProfileModalInfo( + onStartThread: { + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( + with: cellViewModel.authorId, + openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, + openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey + ) + } + }, + onProBadgeTapped: { [weak self, dependencies = viewModel.dependencies] in + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .generic, + dismissType: .single, + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { modal in + dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + } + ) + }, + using: viewModel.dependencies ) - }() - - let qrCodeImage: UIImage? = { - guard let sessionId: String = sessionId else { return nil } - return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore - }() - - let isMessasgeRequestsEnabled: Bool = { - guard cellViewModel.threadVariant == .community else { return true } - - return cellViewModel.profile.blocksCommunityMessageRequests != true - }() + else { return } let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( - info: UserProfileModal.Info( - sessionId: sessionId, - blindedId: blindedId, - qrCodeImage: qrCodeImage, - profileInfo: profileInfo, - displayName: displayName, - contactDisplayName: contactDisplayName, - shouldShowProBadge: cellViewModel.profile.proFeatures.contains(.proBadge), - isMessageRequestsEnabled: isMessasgeRequestsEnabled, - onStartThread: { [weak self] in - Task.detached(priority: .userInitiated) { [weak self] in - await self?.startThread( - with: cellViewModel.authorId, - openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, - openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey - ) - } - }, - onProBadgeTapped: { [weak self, dependencies] in - dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .generic, - dismissType: .single, - afterClosed: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - }, - presenting: { modal in - dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) - } - ) - } - ), - dataManager: dependencies[singleton: .imageDataManager] + info: info, + dataManager: viewModel.dependencies[singleton: .imageDataManager] ) ) present(userProfileModal, animated: true, completion: nil) @@ -2314,6 +2247,9 @@ extension ConversationVC: ) } }, + displayNameRetriever: { [weak self] sessionId, inMessageBody in + self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) + }, using: viewModel.dependencies ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in @@ -2379,41 +2315,17 @@ extension ConversationVC: completion?() } - func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { + @MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { return } guard (cellViewModel.body ?? "")?.isEmpty == false || - cellViewModel.attachments?.isEmpty == false + !cellViewModel.attachments.isEmpty else { return } - let targetAttachment: Attachment? = ( - cellViewModel.attachments?.first ?? - cellViewModel.linkPreviewAttachment - ) - - snInputView.quoteViewModel = QuoteViewModel( - mode: .draft, - direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - rowId: -1, - interactionId: nil, - authorId: cellViewModel.authorId, - showProBadge: self.viewModel.dependencies.mutate(cache: .libSession) { - $0.validateSessionProState(for: cellViewModel.authorId) - }, - timestampMs: cellViewModel.timestampMs, - quotedInteractionId: cellViewModel.id, - quotedInteractionIsDeleted: cellViewModel.variant.isDeletedMessage, - quotedText: cellViewModel.body, - quotedAttachmentInfo: targetAttachment?.quoteAttachmentInfo(using: self.viewModel.dependencies), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: self.viewModel.threadData.threadVariant, - using: self.viewModel.dependencies - ) - ) + snInputView.quoteViewModel = viewModel.draftQuote(for: cellViewModel) // If the `MessageInfoViewController` is visible then we want to show the keyboard after // the pop transition completes (and don't want to delay triggering the completion closure) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 607334f129..b478f05b82 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -350,13 +350,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var snInputView: InputView = InputView( delegate: self, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: self.viewModel.state.threadVariant, - using: self.viewModel.dependencies - ), imageDataManager: self.viewModel.dependencies[singleton: .imageDataManager], linkPreviewManager: self.viewModel.dependencies[singleton: .linkPreviewManager], - sessionProState: self.viewModel.dependencies[singleton: .sessionProState], + sessionProManager: self.viewModel.dependencies[singleton: .sessionProManager], didLoadLinkPreview: nil ) @@ -497,10 +493,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa preconditionFailure("Use init(thread:) instead.") } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - Lifecycle override func viewDidLoad() { @@ -575,13 +567,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Gesture view.addGestureRecognizer(tableViewTapGesture) - // Notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidResignActive(_:)), - name: UIApplication.didEnterBackgroundNotification, object: nil - ) - self.viewModel.navigatableState.setupBindings(viewController: self, disposables: &self.viewModel.disposables) // Bind the UI to the view model @@ -733,58 +718,27 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa updateUnreadCountView(unreadCount: state.threadViewModel.threadUnreadCount) snInputView.setMessageInputState(state.messageInputState) -// if -// initialLoad || -// viewModel.threadData.threadCanWrite != updatedThreadData.threadCanWrite || -// viewModel.threadData.threadVariant != updatedThreadData.threadVariant || -// viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || -// viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || -// viewModel.threadData.closedGroupAdminProfile != updatedThreadData.closedGroupAdminProfile -// { -// UIView.animate(withDuration: 0.3) { [weak self] in -// self?.messageRequestFooterView.update( -// threadVariant: updatedThreadData.threadVariant, -// canWrite: (updatedThreadData.threadCanWrite == true), -// threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true), -// threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true), -// closedGroupAdminProfile: updatedThreadData.closedGroupAdminProfile -// ) -// } -// } + messageRequestFooterView.update( + threadVariant: state.threadVariant, + canWrite: (state.threadViewModel.threadCanWrite == true), + threadIsMessageRequest: (state.threadViewModel.threadIsMessageRequest == true), + threadRequiresApproval: (state.threadViewModel.threadRequiresApproval == true), + closedGroupAdminProfile: state.threadViewModel.closedGroupAdminProfile + ) // Only set the draft content on the initial load (once we have data) if !initialLoadComplete, let draft: String = state.threadViewModel.threadMessageDraft, !draft.isEmpty { - let (string, mentions) = MentionUtilities.getMentions( + let (string, _) = MentionUtilities.getMentions( in: draft, currentUserSessionIds: state.currentUserSessionIds, - displayNameRetriever: { sessionId, _ in + displayNameRetriever: { [weak self] sessionId, inMessageBody in // TODO: [PRO] Replicate this behaviour everywhere - state.profileCache[sessionId]?.displayName(for: state.threadVariant) + self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) } ) + snInputView.text = string snInputView.updateNumberOfCharactersLeft(draft) - - // Fetch the mention info asynchronously - if !mentions.isEmpty { - // TODO: [PRO] Should source these and return them as part of the viewModel -// let adminModMembers: [GroupMember] = try openGroupManager.membersWhere( -// db, -// currentUserSessionIds: (updatedThreadData.currentUserSessionIds ?? []), -// .groupIds([OpenGroup.idFor(roomToken: roomToken, server: server)]), -// .publicKeys(profiles.map { $0.id }), -// .roles([.moderator, .admin]) -// ) - self.mentions = MentionSelectionView.ViewModel.mentions( - profiles: mentions.map { _, profileId, _ in - state.profileCache[profileId] ?? Profile.defaultFor(profileId) - }, - threadVariant: state.threadVariant, - currentUserSessionIds: state.currentUserSessionIds, - adminModMembers: [], - using: viewModel.dependencies - ) - } } // Update the table content @@ -1324,13 +1278,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return } - let profileDisplayName: String = (viewModel.state.profileCache[outdatedMemberId] ?? Profile.defaultFor(outdatedMemberId)).displayName( - for: self.viewModel.state.threadVariant, - suppressId: true - ) self.outdatedClientBanner.update( message: "disappearingMessagesLegacy" - .put(key: "name", value: profileDisplayName) + .put(key: "name", value: (viewModel.displayName(for: outdatedMemberId, inMessageBody: true) ?? outdatedMemberId.truncated())) .localizedFormatted(baseFont: self.outdatedClientBanner.font), onTap: { [weak self] in self?.removeOutdatedClientBanner() } ) @@ -1405,6 +1355,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa .contains(cellViewModel.id), lastSearchText: viewModel.lastSearchedText, tableSize: tableView.bounds.size, + displayNameRetriever: { [weak self] sessionId, inMessageBody in + self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) + }, using: viewModel.dependencies ) cell.delegate = self diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index a5a0d20c8b..8d57c60ba6 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -86,9 +86,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold @MainActor @Published private(set) var state: State private var observationTask: Task? - // TODO: [PRO] Remove this value (access via `state`) - public var isCurrentUserSessionPro: Bool { dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro } - // MARK: - Initialization @MainActor init( @@ -155,8 +152,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let interactionCache: [Int64: Interaction] let attachmentCache: [String: Attachment] let reactionCache: [Int64: [Reaction]] - let quoteMap: [Int64: Int64] + let quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] let attachmentMap: [Int64: Set] + let unblindedIdMap: [String: String] let modAdminCache: Set let itemCache: [Int64: MessageViewModel] @@ -223,13 +221,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .appending(string: " ") // In case it's a RTL font } - var messageInputState: SessionThreadViewModel.MessageInputState { - guard !threadViewModel.threadIsNoteToSelf else { - return SessionThreadViewModel.MessageInputState(allowedInputTypes: .all) - } + public var messageInputState: InputView.InputState { + guard !threadViewModel.threadIsNoteToSelf else { return InputView.InputState(inputs: .all) } guard threadViewModel.threadIsBlocked != true else { - return SessionThreadViewModel.MessageInputState( - allowedInputTypes: .none, + return InputView.InputState( + inputs: .disabled, message: "blockBlockedDescription".localized(), messageAccessibility: Accessibility( identifier: "Blocked banner" @@ -238,17 +234,22 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } if threadViewModel.threadVariant == .community && threadViewModel.threadCanWrite == false { - return SessionThreadViewModel.MessageInputState( - allowedInputTypes: .none, + return InputView.InputState( + inputs: .disabled, message: "permissionsWriteCommunity".localized() ) } - return SessionThreadViewModel.MessageInputState( - allowedInputTypes: (threadViewModel.threadRequiresApproval == false && threadViewModel.threadIsMessageRequest == false ? - .all : - .textOnly - ) + /// Attachments shouldn't be allowed for message requests or if uploads are disabled + let finalInputs: InputView.Input + + switch (threadViewModel.threadRequiresApproval, threadViewModel.threadIsMessageRequest, threadViewModel.threadCanUpload) { + case (false, false, true): finalInputs = .all + default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] + } + + return InputView.InputState( + inputs: finalInputs ) } @@ -285,10 +286,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .community: result.insert(.communityUpdated(threadId)) + result.insert(.anyContactUnblinded) default: break } + /// Observe changes to messages interactionCache.keys.forEach { messageId in result.insert(.messageUpdated(id: messageId, threadId: threadId)) result.insert(.messageDeleted(id: messageId, threadId: threadId)) @@ -301,6 +304,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } } + /// Observe changes to profile data + profileCache.forEach { profileId, _ in + result.insert(.profile(profileId)) + } + return result } @@ -337,6 +345,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold reactionCache: [:], quoteMap: [:], attachmentMap: [:], + unblindedIdMap: [:], modAdminCache: [], itemCache: [:], titleViewModel: ConversationTitleViewModel( @@ -436,8 +445,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold var interactionCache: [Int64: Interaction] = previousState.interactionCache var attachmentCache: [String: Attachment] = previousState.attachmentCache var reactionCache: [Int64: [Reaction]] = previousState.reactionCache - var quoteMap: [Int64: Int64] = previousState.quoteMap + var quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = previousState.quoteMap var attachmentMap: [Int64: Set] = previousState.attachmentMap + var unblindedIdMap: [String: String] = previousState.unblindedIdMap var modAdminCache: Set = previousState.modAdminCache var itemCache: [Int64: MessageViewModel] = previousState.itemCache var threadViewModel: SessionThreadViewModel = previousState.threadViewModel @@ -516,24 +526,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold guard !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let splitEvents: [EventDataRequirement: Set] = eventsToProcess - .reduce(into: [:]) { result, next in - switch next.dataRequirement { - case .databaseQuery: result[.databaseQuery, default: []].insert(next) - case .other: result[.other, default: []].insert(next) - case .bothDatabaseQueryAndOther: - result[.databaseQuery, default: []].insert(next) - result[.other, default: []].insert(next) - } - } - var databaseEvents: Set = (splitEvents[.databaseQuery] ?? []) - let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? - .reduce(into: [:]) { result, event in - result[event.key.generic, default: []].insert(event) - } - var loadPageEvent: LoadPageEvent? = splitEvents[.databaseQuery]? - .first(where: { $0.key.generic == .loadPage })? - .value as? LoadPageEvent + let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) + var databaseEvents: Set = changes.databaseEvents + var loadPageEvent: LoadPageEvent? = changes.latest(.loadPage, as: LoadPageEvent.self) // FIXME: We should be able to make this far more efficient by splitting this query up and only fetching diffs var threadNeedsRefresh: Bool = ( @@ -548,10 +543,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// Handle thread specific changes first (as this could include a conversation being unblinded) switch threadVariant { case .contact: - groupedOtherEvents?[.contact]?.forEach { event in - guard let eventValue: ContactEvent = event.value as? ContactEvent else { return } - - switch eventValue.change { + changes.forEach(.contact, as: ContactEvent.self) { event in + switch event.change { case .isTrusted(let value): threadContact = threadContact?.with( isTrusted: .set(to: value), @@ -598,13 +591,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } case .legacyGroup, .group: - groupedOtherEvents?[.groupMemberUpdated]?.forEach { event in - guard let eventValue: GroupMemberEvent = event.value as? GroupMemberEvent else { return } - - switch eventValue.change { + changes.forEach(.groupMemberUpdated, as: GroupMemberEvent.self) { event in + switch event.change { case .none: break case .role(let role, _): - guard eventValue.profileId == previousState.userSessionId.hexString else { return } + guard event.profileId == previousState.userSessionId.hexString else { return } isUserModeratorOrAdmin = (role == .admin) } @@ -612,10 +603,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .community: /// Handle community changes (users could change to mods which would need all of their interaction data updated) - groupedOtherEvents?[.communityUpdated]?.forEach { event in - guard let eventValue: CommunityEvent = event.value as? CommunityEvent else { return } - - switch eventValue.change { + changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in + switch event.change { case .receivedInitialMessages: /// If we already have a `loadPageEvent` then that takes prescedence, otherwise we should load /// the initial page once we've received the initial messages for a community @@ -637,15 +626,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } /// Profile events - groupedOtherEvents?[.profile]?.forEach { event in - guard let eventValue: ProfileEvent = event.value as? ProfileEvent else { return } - guard var profileData: Profile = profileCache[eventValue.id] else { + changes.forEach(.profile, as: ProfileEvent.self) { event in + guard var profileData: Profile = profileCache[event.id] else { /// This profile (somehow) isn't in the cache, so we need to fetch it - profileIdsNeedingFetch.insert(eventValue.id) + profileIdsNeedingFetch.insert(event.id) return } - switch eventValue.change { + switch event.change { case .name(let name): profileData = profileData.with(name: name) case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) @@ -664,7 +652,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - profileCache[eventValue.id] = profileData + profileCache[event.id] = profileData + } + + /// General unblinding handling + changes.forEach(.anyContactUnblinded, as: ContactEvent.self) { event in + switch event.change { + case .unblinded(let blindedId, let unblindedId): unblindedIdMap[blindedId] = unblindedId + default: break + } } /// Pull data from libSession @@ -693,7 +689,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold var fetchedAttachments: [Attachment] = [] var fetchedInteractionAttachments: [InteractionAttachment] = [] var fetchedReactions: [Int64: [Reaction]] = [:] - var fetchedQuoteMap: [Int64: Int64] = [:] + var fetchedQuoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = [:] + var fetchedBlindedIdLookups: [BlindedIdLookup] = [] /// Identify any inserted/deleted records var insertedInteractionIds: Set = [] @@ -822,16 +819,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } /// Get the ids of any quoted interactions - let quoteInteractionIdResults: Set> = try MessageViewModel + /// + /// **Note:** We may not be able to find the quoted interaction (hence the `Int64?` but would still want to render + /// the message as a quote) + let quoteInteractionIdResults: Set> = try MessageViewModel .quotedInteractionIds( for: interactionIdsNeedingFetch, currentUserSessionIds: currentUserSessionIds ) .fetchSet(db) quoteInteractionIdResults.forEach { pair in - fetchedQuoteMap[pair.first] = pair.second + fetchedQuoteMap[pair.first] = MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: pair.second + ) } - interactionIdsNeedingFetch += Array(fetchedQuoteMap.values) + interactionIdsNeedingFetch += Array(fetchedQuoteMap.values.compactMap { $0.foundQuotedInteractionId }) /// Fetch any records needed fetchedInteractions = try Interaction.fetchAll(db, ids: interactionIdsNeedingFetch) @@ -848,6 +850,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold fetchedProfiles = try Profile.fetchAll(db, ids: Array(missingProfileIds)) } + fetchedBlindedIdLookups = try BlindedIdLookup + .filter(ids: Set(fetchedProfiles.map { $0.id })) + .filter(BlindedIdLookup.Columns.sessionId != nil) + .fetchAll(db) + /// Fetch any link previews needed let linkPreviewLookupInfo: [(url: String, timestamp: Int64)] = fetchedInteractions.compactMap { guard let url: String = $0.linkPreviewUrl else { return nil } @@ -932,6 +939,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold reactionCache[interactionId, default: []] = reactions } + fetchedBlindedIdLookups.forEach { unblindedIdMap[$0.blindedId] = $0.sessionId } let groupedInteractionAttachments: [Int64: Set] = fetchedInteractionAttachments .grouped(by: \.interactionId) .mapValues { Set($0) } @@ -986,19 +994,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } /// Update the typing indicator state if needed - groupedOtherEvents?[.typingIndicator]?.forEach { event in - guard let eventValue: TypingIndicatorEvent = event.value as? TypingIndicatorEvent else { return } - - shouldShowTypingIndicator = (eventValue.change == .started) + changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in + shouldShowTypingIndicator = (event.change == .started) } /// Handle optimistic messages - groupedOtherEvents?[.updateScreen]?.forEach { event in - guard let eventValue: ConversationViewModelEvent = event.value as? ConversationViewModelEvent else { - return - } - - switch eventValue { + changes.forEach(.updateScreen, as: ConversationViewModelEvent.self) { event in + switch event { case .sendMessage(let data): optimisticallyInsertedMessages[data.temporaryId] = data @@ -1013,11 +1015,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold }) } - if let draft: LinkPreviewDraft = data.linkPreviewDraft { - linkPreviewCache[draft.urlString, default: []].append( + if let viewModel: LinkPreviewViewModel = data.linkPreviewViewModel { + linkPreviewCache[viewModel.urlString, default: []].append( LinkPreview( - url: draft.urlString, - title: draft.title, + url: viewModel.urlString, + title: viewModel.title, attachmentId: nil, /// Can't save to db optimistically using: dependencies ) @@ -1036,9 +1038,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold mostRecentFailureText: "shareExtensionDatabaseError".localized() ), attachmentData: data.attachmentData, - linkPreviewDraft: data.linkPreviewDraft, + linkPreviewViewModel: data.linkPreviewViewModel, linkPreviewPreparedAttachment: data.linkPreviewPreparedAttachment, - quoteModel: data.quoteModel + quoteViewModel: data.quoteViewModel ) case .resolveOptimisticMessage(let temporaryId, let databaseId): @@ -1067,7 +1069,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let optimisticMessageId: Int64? let interaction: Interaction let reactionInfo: [MessageViewModel.ReactionInfo]? - let quotedInteraction: Interaction? + let maybeUnresolvedQuotedInfo: MessageViewModel.MaybeUnresolvedQuotedInfo? /// Source the interaction data from the appropriate location switch id { @@ -1077,10 +1079,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold optimisticMessageId = data.temporaryId interaction = data.interaction reactionInfo = nil /// Can't react to an optimistic message - quotedInteraction = data.quoteModel.map { model -> Interaction? in - guard let interactionId: Int64 = model.quotedInteractionId else { return nil } + maybeUnresolvedQuotedInfo = data.quoteViewModel.map { model -> MessageViewModel.MaybeUnresolvedQuotedInfo? in + guard let interactionId: Int64 = model.quotedInfo?.interactionId else { return nil } - return quoteMap[interactionId].map { interactionCache[$0] } + return MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: interactionId, + resolvedQuotedInteraction: interactionCache[interactionId] + ) } default: @@ -1103,7 +1108,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } } - quotedInteraction = quoteMap[id].map { interactionCache[$0] } + maybeUnresolvedQuotedInfo = quoteMap[id].map { info in + MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: info.foundQuotedInteractionId, + resolvedQuotedInteraction: info.foundQuotedInteractionId.map { interactionCache[$0] } + ) + } } itemCache[id] = MessageViewModel( @@ -1114,11 +1124,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadDisappearingConfiguration: threadViewModel.disappearingMessagesConfiguration, interaction: interaction, reactionInfo: reactionInfo, - quotedInteraction: quotedInteraction, + maybeUnresolvedQuotedInfo: maybeUnresolvedQuotedInfo, profileCache: profileCache, attachmentCache: attachmentCache, linkPreviewCache: linkPreviewCache, attachmentMap: attachmentMap, + unblindedIdMap: unblindedIdMap, isSenderModeratorOrAdmin: modAdminCache.contains(interaction.authorId), userSessionId: previousState.userSessionId, currentUserSessionIds: currentUserSessionIds, @@ -1178,6 +1189,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold reactionCache: reactionCache, quoteMap: quoteMap, attachmentMap: attachmentMap, + unblindedIdMap: unblindedIdMap, modAdminCache: modAdminCache, itemCache: itemCache, titleViewModel: ConversationTitleViewModel( @@ -1363,10 +1375,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - // MARK: - Mentions + // MARK: - Profiles + + @MainActor public func displayName(for sessionId: String, inMessageBody: Bool) -> String? { + return state.profileCache[sessionId]?.displayName( + includeSessionIdSuffix: (state.threadVariant == .community && inMessageBody) + ) + } public func mentions(for query: String = "") async throws -> [MentionSelectionView.ViewModel] { - let userSessionId: SessionId = dependencies[cache: .general].sessionId let state: State = await self.state return try await MentionSelectionView.ViewModel.mentions( @@ -1380,6 +1397,33 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) } + + @MainActor public func draftQuote(for viewModel: MessageViewModel) -> QuoteViewModel { + let targetAttachment: Attachment? = ( + viewModel.attachments.first ?? + viewModel.linkPreviewAttachment + ) + + return QuoteViewModel( + mode: .draft, + direction: (viewModel.variant == .standardOutgoing ? .outgoing : .incoming), + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: viewModel.id, + authorId: viewModel.authorId, + authorName: viewModel.authorName(), + timestampMs: viewModel.timestampMs, + body: viewModel.body, + attachmentInfo: targetAttachment?.quoteAttachmentInfo(using: dependencies) + ), + showProBadge: viewModel.profile.proFeatures.contains(.proBadge), /// Quote pro badge is profile data + currentUserSessionIds: viewModel.currentUserSessionIds, + displayNameRetriever: { [profileCache = state.profileCache] sessionId, inMessageBody in + profileCache[sessionId]?.displayName( + includeSessionIdSuffix: (viewModel.threadVariant == .community && inMessageBody) + ) + } + ) + } // MARK: - Functions @@ -1411,22 +1455,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold @MainActor public func updateDraft(to draft: String) { /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this, /// worst case the `draft` just won't be saved) - Task.detached(priority: .userInitiated) { [threadId = state.threadId, dependencies] in - let existingDraft: String? = try? await dependencies[singleton: .storage].readAsync { db in - try SessionThread - .select(.messageDraft) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) - } - - guard draft != existingDraft else { return } - - _ = try? await dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) - } + Task.detached(priority: .userInitiated) { [threadViewModel = state.threadViewModel, dependencies] in + do { try await threadViewModel.updateDraft(draft, using: dependencies) } + catch { Log.error(.conversation, "Failed to update draft due to error: \(error)") } } } @@ -1827,12 +1858,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Convenience -private enum EventDataRequirement { - case databaseQuery - case other - case bothDatabaseQueryAndOther -} - private extension ObservedEvent { var dataRequirement: EventDataRequirement { // FIXME: Should be able to optimise this further @@ -1840,6 +1865,7 @@ private extension ObservedEvent { case (_, .loadPage): return .databaseQuery case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (.anyContactUnblinded, _): return .bothDatabaseQueryAndOther case (_, .typingIndicator): return .databaseQuery case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index 75fbbfa946..1586f9912e 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -131,6 +131,7 @@ final class CallMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard diff --git a/Session/Conversations/Message Cells/DateHeaderCell.swift b/Session/Conversations/Message Cells/DateHeaderCell.swift index f06c123333..1bb94fc3b2 100644 --- a/Session/Conversations/Message Cells/DateHeaderCell.swift +++ b/Session/Conversations/Message Cells/DateHeaderCell.swift @@ -46,6 +46,7 @@ final class DateHeaderCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .dateHeader else { return } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index 5e7c519a3a..f829f982ca 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -94,6 +94,7 @@ final class InfoMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.variant.isInfoMessage else { return } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index a4e2cd8585..aa2381e0df 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -90,6 +91,7 @@ public class MessageCell: UITableViewCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { preconditionFailure("Must be overridden by subclasses.") diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 42b530ffc3..307d5b52c2 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -47,6 +47,7 @@ final class TypingIndicatorCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .typingIndicator else { return } diff --git a/Session/Conversations/Message Cells/UnreadMarkerCell.swift b/Session/Conversations/Message Cells/UnreadMarkerCell.swift index 12313e50d5..8926b11d8c 100644 --- a/Session/Conversations/Message Cells/UnreadMarkerCell.swift +++ b/Session/Conversations/Message Cells/UnreadMarkerCell.swift @@ -67,6 +67,7 @@ final class UnreadMarkerCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .unreadMarker else { return } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index c44df2c7b5..df9519bc43 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -326,6 +326,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { self.dependencies = dependencies @@ -379,6 +380,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { shouldExpanded: shouldExpanded, lastSearchText: lastSearchText, tableSize: tableSize, + displayNameRetriever: displayNameRetriever, using: dependencies ) @@ -388,8 +390,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Author label authorLabel.isHidden = !cellViewModel.shouldShowAuthorName - authorLabel.text = cellViewModel.authorNameSuppressedId - authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) + authorLabel.text = cellViewModel.authorName() + authorLabel.extraText = (cellViewModel.threadVariant == .community ? + "(\(cellViewModel.authorId.truncated()))" : /// Show a truncated authorId in Community conversations // stringlint:ignore + nil + ) authorLabel.themeTextColor = .textPrimary authorLabel.isProBadgeHidden = !cellViewModel.proFeatures.contains(.proBadge) @@ -490,6 +495,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { let bodyLabelTextColor: ThemeValue = (cellViewModel.variant.isOutgoing ? @@ -582,7 +588,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self, - using: dependencies + displayNameRetriever: displayNameRetriever ) bodyTappableLabelContainer.addSubview(bodyTappableInfo.label) @@ -634,16 +640,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if let quoteViewModel: QuoteViewModel = cellViewModel.quoteViewModel { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -658,7 +655,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self, - using: dependencies + displayNameRetriever: displayNameRetriever ) self.bodyTappableLabel = bodyTappableLabel self.bodyTappableLabelHeight = height @@ -729,16 +726,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Quote view let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -752,7 +740,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self, - using: dependencies + displayNameRetriever: displayNameRetriever ) self.bodyTappableLabel = bodyTappableLabel self.bodyTappableLabelHeight = height @@ -786,7 +774,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self, - using: dependencies + displayNameRetriever: displayNameRetriever ) self.bodyTappableLabel = bodyTappableLabel @@ -811,16 +799,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { /// Just quote case (.some(let quoteViewModel), _): let quoteView: QuoteView = QuoteView( - viewModel: quoteViewModel.with( - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: cellViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) self.quoteView = quoteView @@ -1082,7 +1061,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) let tappedAuthorName: Bool = ( authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && - !cellViewModel.authorName.isEmpty + !cellViewModel.authorName().isEmpty ) let tappedProfilePicture: Bool = ( profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && @@ -1313,7 +1292,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { for cellViewModel: MessageViewModel, textColor: ThemeValue, searchText: String?, - using dependencies: Dependencies + displayNameRetriever: DisplayNameRetriever ) -> ThemedAttributedString? { guard let body: String = cellViewModel.body, @@ -1323,7 +1302,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) let attributedText: ThemedAttributedString = MentionUtilities.highlightMentions( in: body, - threadVariant: cellViewModel.threadVariant, currentUserSessionIds: cellViewModel.currentUserSessionIds, location: (isOutgoing ? .outgoingMessage : .incomingMessage), textColor: textColor, @@ -1331,7 +1309,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { .themeForegroundColor: textColor, .font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) ], - using: dependencies + displayNameRetriever: displayNameRetriever ) // Custom handle links @@ -1441,7 +1419,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { textColor: ThemeValue, searchText: String?, delegate: TappableLabelDelegate?, - using dependencies: Dependencies + displayNameRetriever: DisplayNameRetriever ) -> (label: TappableLabel, height: CGFloat) { let result: TappableLabel = TappableLabel() result.setContentHugging(.vertical, to: .required) @@ -1450,7 +1428,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { for: cellViewModel, textColor: textColor, searchText: searchText, - using: dependencies + displayNameRetriever: displayNameRetriever ) result.themeBackgroundColor = .clear result.isOpaque = false diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 96107f5d01..86c787c57e 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -2159,7 +2159,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi displayName: ( /// **Note:** We want to use the `profile` directly rather than `threadViewModel.displayName` /// as the latter would use the `nickname` here which is incorrect - threadViewModel.profile?.displayName(ignoringNickname: true) ?? + threadViewModel.profile?.displayName(ignoreNickname: true) ?? threadViewModel.threadId.truncated() ) ) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 918047d8c2..1f772bab6c 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -455,7 +455,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { leadingAccessory: .profile(id: authorId, profile: cellViewModel.profile), title: ( cellViewModel.profile?.displayName() ?? - authorId.truncated(threadVariant: self.messageViewModel?.threadVariant ?? .contact) + authorId.truncated() ), trailingAccessory: (!canRemoveEmoji ? nil : .icon( diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 74ba8294cf..b965d90b9d 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -619,8 +619,7 @@ public class HomeViewModel: NavigatableStateHolder { ].flatMap { $0 } } - @MainActor - func viewDidAppear() { + @MainActor func viewDidAppear() { if state.pendingAppReviewPromptState != nil { // Handle App review DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in @@ -636,7 +635,7 @@ public class HomeViewModel: NavigatableStateHolder { willShowCameraPermissionReminder() // Pro expiring/expired CTA - showSessionProCTAIfNeeded() + Task { await showSessionProCTAIfNeeded() } } func scheduleAppReviewRetry() { @@ -645,36 +644,49 @@ public class HomeViewModel: NavigatableStateHolder { .addingTimeInterval(2 * 7 * 24 * 60 * 60) } - func showSessionProCTAIfNeeded() { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .none, .refunding: + @MainActor func showSessionProCTAIfNeeded() async { + switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { + case .none, .neverBeenPro: return - case .active(_, let expiredOn, _ , _): - let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow + + case .active: + let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] + .accessExpiryTimestampMs + .first() + .map { value in value.map { Date(timeIntervalSince1970: (Double($0) / 1000)) } } + .map { $0.timeIntervalSince(dependencies.dateNow) } ?? 0) guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return } guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .expiring(timeLeft: expiryInSeconds.formatted(format: .long, allowedUnits: [ .day, .hour, .minute ])), - presenting: { modal in - dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } - case .expired(let expiredOn, _): - let expiryInSeconds: TimeInterval = expiredOn.timeIntervalSinceNow + + try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread + + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .expiring(timeLeft: expiryInSeconds.formatted(format: .long, allowedUnits: [ .day, .hour, .minute ])), + presenting: { [weak self, dependencies] modal in + dependencies[defaults: .standard, key: .hasShownProExpiringCTA] = true + self?.transitionToScreen(modal, transitionType: .present) + } + ) + + case .expired: + let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] + .accessExpiryTimestampMs + .first() + .map { value in value.map { Date(timeIntervalSince1970: (Double($0) / 1000)) } } + .map { $0.timeIntervalSince(dependencies.dateNow) } ?? 0) + guard expiryInSeconds <= 30 * 24 * 60 * 60 else { return } guard !dependencies[defaults: .standard, key: .hasShownProExpiredCTA] else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .expiring(timeLeft: nil), - presenting: { modal in - dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = true - self?.transitionToScreen(modal, transitionType: .present) - } - ) - } + + try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread + + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .expiring(timeLeft: nil), + presenting: { [weak self, dependencies] modal in + dependencies[defaults: .standard, key: .hasShownProExpiredCTA] = true + self?.transitionToScreen(modal, transitionType: .present) + } + ) } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index cc1ea0cc96..eb6f8283c8 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -173,6 +173,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .grouped(by: \.requiresDatabaseQueryForMessageRequestsViewModel) /// Handle database events first + let userSessionId: SessionId = dependencies[cache: .general].sessionId + if let databaseEvents: Set = splitEvents[true].map({ Set($0) }) { do { var fetchedConversations: [SessionThreadViewModel] = [] @@ -222,7 +224,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O fetchedConversations.append( contentsOf: try SessionThreadViewModel .query( - userSessionId: dependencies[cache: .general].sessionId, + userSessionId: userSessionId, groupSQL: SessionThreadViewModel.groupSQL, orderSQL: SessionThreadViewModel.messageRequestsOrderSQL, ids: Array(idsNeedingRequery) + loadResult.newIds @@ -232,7 +234,29 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } /// Update the `itemCache` with the newly fetched values - fetchedConversations.forEach { itemCache[$0.threadId] = $0 } + fetchedConversations.forEach { thread in + let result: (wasKickedFromGroup: Bool, groupIsDestroyed: Bool) = { + guard thread.threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: thread.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) + } + }() + + itemCache[thread.threadId] = thread.populatingPostQueryData( + recentReactionEmoji: nil, + openGroupCapabilities: nil, + currentUserSessionIds: [userSessionId.hexString], + wasKickedFromGroup: result.wasKickedFromGroup, + groupIsDestroyed: result.groupIsDestroyed, + threadCanWrite: thread.determineInitialCanWriteFlag(using: dependencies), + threadCanUpload: thread.determineInitialCanUploadFlag(using: dependencies) + ) + } /// Remove any deleted values deletedIds.forEach { id in itemCache.removeValue(forKey: id) } @@ -298,34 +322,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .compactMap { state.itemCache[$0] } .map { conversation -> SessionCell.Info in return SessionCell.Info( - id: conversation.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [viewModel.dependencies[cache: .general].sessionId.hexString], - wasKickedFromGroup: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - groupIsDestroyed: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - threadCanWrite: conversation.determineInitialCanWriteFlag( - using: viewModel.dependencies - ), - threadCanUpload: conversation.determineInitialCanUploadFlag( - using: viewModel.dependencies - ) - ), + id: conversation, accessibility: Accessibility( identifier: "Message request" ), diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index c43dbcf40e..0e3db725ab 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -816,8 +816,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou .read { db in Profile.displayName( db, - id: targetItem.interactionAuthorId, - threadVariant: threadVariant + id: targetItem.interactionAuthorId ) } .defaulting(to: targetItem.interactionAuthorId.truncated()) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 23944ac7a0..6cfc128d46 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -25,6 +25,8 @@ struct MessageInfoScreen: View { /// the state the user was in when the message was sent let shouldShowProBadge: Bool + let displayNameRetriever: DisplayNameRetriever + func ctaVariant(currentUserIsPro: Bool) -> ProCTAModal.Variant { guard let firstFeature: ProFeature = proFeatures.first, proFeatures.count > 1 else { return .generic @@ -89,6 +91,7 @@ struct MessageInfoScreen: View { messageViewModel: MessageViewModel, threadCanWrite: Bool, onStartThread: (@MainActor () -> Void)?, + displayNameRetriever: @escaping DisplayNameRetriever, using dependencies: Dependencies ) { self.viewModel = ViewModel( @@ -109,7 +112,8 @@ struct MessageInfoScreen: View { using: dependencies ).front, proFeatures: ProFeature.from(messageViewModel.proFeatures), - shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge) + shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge), + displayNameRetriever: displayNameRetriever ) } @@ -128,6 +132,7 @@ struct MessageInfoScreen: View { MessageBubble( messageViewModel: viewModel.messageViewModel, attachmentOnly: false, + displayNameRetriever: viewModel.displayNameRetriever, dependencies: viewModel.dependencies ) .clipShape( @@ -240,6 +245,7 @@ struct MessageInfoScreen: View { MessageBubble( messageViewModel: viewModel.messageViewModel, attachmentOnly: true, + displayNameRetriever: viewModel.displayNameRetriever, dependencies: viewModel.dependencies ) .clipShape( @@ -443,8 +449,8 @@ struct MessageInfoScreen: View { .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - else if !viewModel.messageViewModel.authorNameSuppressedId.isEmpty { - Text(viewModel.messageViewModel.authorNameSuppressedId) + else if !viewModel.messageViewModel.authorName().isEmpty { + Text(viewModel.messageViewModel.authorName()) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } @@ -593,86 +599,18 @@ struct MessageInfoScreen: View { } func showUserProfileModal() { - guard viewModel.threadCanWrite else { return } - // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) - guard (try? SessionId.Prefix(from: viewModel.messageViewModel.authorId)) != .blinded25 else { return } - - guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom( - size: .message, - publicKey: viewModel.messageViewModel.profile.id, - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: viewModel.messageViewModel.profile, - profileIcon: .none, - using: viewModel.dependencies - ).front else { - return - } - - // TODO: [PRO] Would be good to source this from the view model if we can - let (sessionId, blindedId): (String?, String?) = { - guard - (try? SessionId.Prefix(from: viewModel.messageViewModel.authorId)) == .blinded15, - let openGroupServer: String = viewModel.messageViewModel.threadOpenGroupServer, - let openGroupPublicKey: String = viewModel.messageViewModel.threadOpenGroupPublicKey - else { return (viewModel.messageViewModel.authorId, nil) } - - let lookup: BlindedIdLookup? = viewModel.dependencies[singleton: .storage].write { db in - try BlindedIdLookup.fetchOrCreate( - db, - blindedId: viewModel.messageViewModel.authorId, - openGroupServer: openGroupServer, - openGroupPublicKey: openGroupPublicKey, - isCheckingForOutbox: false, - using: viewModel.dependencies - ) - } - - return (lookup?.sessionId, viewModel.messageViewModel.authorId.truncated(prefix: 10, suffix: 10)) - }() - - let qrCodeImage: UIImage? = { - guard let sessionId: String = sessionId else { return nil } - return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore - }() - - let isMessasgeRequestsEnabled: Bool = { - guard viewModel.messageViewModel.threadVariant == .community else { return true } - return viewModel.messageViewModel.profile.blocksCommunityMessageRequests != true - }() - - let (displayName, contactDisplayName): (String?, String?) = { - guard let sessionId: String = sessionId else { - return (viewModel.messageViewModel.authorNameSuppressedId, nil) - } - - guard !viewModel.messageViewModel.currentUserSessionIds.contains(sessionId) else { - return ("you".localized(), "you".localized()) - } - - return ( - viewModel.messageViewModel.authorName, - viewModel.messageViewModel.profile.displayName( - for: viewModel.messageViewModel.threadVariant, - ignoringNickname: true - ) + guard + viewModel.threadCanWrite, + let info: UserProfileModal.Info = viewModel.messageViewModel.createUserProfileModalInfo( + onStartThread: viewModel.onStartThread, + onProBadgeTapped: self.showSessionProCTAIfNeeded, + using: viewModel.dependencies ) - }() + else { return } let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( - info: UserProfileModal.Info( - sessionId: sessionId, - blindedId: blindedId, - qrCodeImage: qrCodeImage, - profileInfo: profileInfo, - displayName: displayName, - contactDisplayName: contactDisplayName, - shouldShowProBadge: viewModel.messageViewModel.profile.proFeatures.contains(.proBadge), - isMessageRequestsEnabled: isMessasgeRequestsEnabled, - onStartThread: viewModel.onStartThread, - onProBadgeTapped: self.showSessionProCTAIfNeeded - ), + info: info, dataManager: viewModel.dependencies[singleton: .imageDataManager] ) ) @@ -709,6 +647,7 @@ struct MessageBubble: View { let messageViewModel: MessageViewModel let attachmentOnly: Bool + let displayNameRetriever: DisplayNameRetriever let dependencies: Dependencies var bodyLabelTextColor: ThemeValue { @@ -732,7 +671,7 @@ struct MessageBubble: View { textColor: bodyLabelTextColor, searchText: nil, delegate: nil, - using: dependencies + displayNameRetriever: displayNameRetriever ).height VStack( @@ -768,17 +707,7 @@ struct MessageBubble: View { else { if let quoteViewModel: QuoteViewModel = messageViewModel.quoteViewModel { QuoteView_SwiftUI( - viewModel: quoteViewModel.with( - thumbnailSource: .thumbnailFrom( - quoteViewModel: quoteViewModel, - using: dependencies - ), - // TODO: [PRO] Can we source this from the 'MessageViewModel'? Provide the 'profileCache'? - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: messageViewModel.threadVariant, - using: dependencies - ) - ), + viewModel: quoteViewModel, dataManager: dependencies[singleton: .imageDataManager] ) .fixedSize(horizontal: false, vertical: true) @@ -795,7 +724,7 @@ struct MessageBubble: View { for: messageViewModel, textColor: bodyLabelTextColor, searchText: nil, - using: dependencies + displayNameRetriever: displayNameRetriever ) { AttributedLabel(bodyText, maxWidth: maxWidth) .padding(.horizontal, Self.inset) @@ -880,6 +809,7 @@ final class MessageInfoViewController: SessionHostingViewController Void)?, + displayNameRetriever: @escaping DisplayNameRetriever, using dependencies: Dependencies ) { let messageInfoView = MessageInfoScreen( @@ -887,6 +817,7 @@ final class MessageInfoViewController: SessionHostingViewController Int { - return LibSession.numberOfCharactersLeft( - for: text, - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) + return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 842f9f84e3..4a2454cdca 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -382,13 +382,18 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Current: \(mockedProStatus) """, trailingAccessory: .icon(.squarePen), - onTap: { [weak viewModel] in + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in DeveloperSettingsViewModel.showModalForMockableState( title: "Mocked Pro Status", explanation: "Force the current users Session Pro to a specific status locally.", feature: .mockCurrentUserSessionProBackendStatus, currentValue: state.mockCurrentUserSessionProBackendStatus, navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, using: viewModel?.dependencies ) } @@ -404,22 +409,28 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. """, trailingAccessory: .icon(.squarePen), - isEnabled: ( - state.mockCurrentUserSessionProLoadingState != nil || - state.mockCurrentUserSessionProBackendStatus != nil || - state.currentProStatus != nil - ), - onTap: { [weak viewModel] in + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in DeveloperSettingsViewModel.showModalForMockableState( title: "Mocked Loading State", explanation: "Force the Session Pro UI into a specific loading state.", feature: .mockCurrentUserSessionProLoadingState, currentValue: state.mockCurrentUserSessionProLoadingState, navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, using: viewModel?.dependencies ) } - ) + ), SessionCell.Info( id: .proBadgeEverywhere, title: "Show the Pro Badge everywhere", @@ -873,10 +884,16 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: nil) + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } } if dependencies.hasSet(feature: .mockCurrentUserSessionProLoadingState) { dependencies.set(feature: .mockCurrentUserSessionProLoadingState, to: nil) + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } } } @@ -1241,32 +1258,3 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } } - -extension Product: @retroactive Comparable { - public static func < (lhs: Product, rhs: Product) -> Bool { - guard - let lhsSubscription: SubscriptionInfo = lhs.subscription, - let rhsSubscription: SubscriptionInfo = rhs.subscription, ( - lhsSubscription.subscriptionPeriod.unit != rhsSubscription.subscriptionPeriod.unit || - lhsSubscription.subscriptionPeriod.value != rhsSubscription.subscriptionPeriod.value - ) - else { return lhs.id < rhs.id } - - func approximateDurationDays(_ subscription: SubscriptionInfo) -> Int { - switch subscription.subscriptionPeriod.unit { - case .day: return subscription.subscriptionPeriod.value - case .week: return subscription.subscriptionPeriod.value * 7 - case .month: return subscription.subscriptionPeriod.value * 30 - case .year: return subscription.subscriptionPeriod.value * 365 - @unknown default: return subscription.subscriptionPeriod.value - } - } - - let lhsApproxDays: Int = approximateDurationDays(lhsSubscription) - let rhsApproxDays: Int = approximateDurationDays(rhsSubscription) - - guard lhsApproxDays != rhsApproxDays else { return lhs.id < rhs.id } - - return (lhsApproxDays < rhsApproxDays) - } -} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 5be5f105aa..6d47bcf042 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -1796,6 +1796,7 @@ extension DeveloperSettingsViewModel { feature: FeatureConfig>, currentValue: MockableFeature, navigatableStateHolder: NavigatableStateHolder?, + onMockingRemoved: (() -> Void)? = nil, using dependencies: Dependencies? ) { let allCases: [MockableFeature] = MockableFeature.allCases @@ -1845,7 +1846,10 @@ extension DeveloperSettingsViewModel { }() switch selectedValue { - case .none, .useActual: dependencies?.set(feature: feature, to: nil) + case .none, .useActual: + dependencies?.set(feature: feature, to: nil) + onMockingRemoved?() + case .simulate: dependencies?.set(feature: feature, to: selectedValue) } } diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 71b8cd7273..f2825afca2 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -144,8 +144,8 @@ final class NukeDataModal: Modal { title: "clearDataAll".localized(), body: .attributedText( { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .active, .refunding: + switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { + case .active: "proClearAllDataNetwork" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) @@ -169,8 +169,8 @@ final class NukeDataModal: Modal { } private func clearDeviceOnly() { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value { - case .active, .refunding: + switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { + case .active: let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "clearDataAll".localized(), diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index a686d1f771..13baf05106 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -8,9 +8,18 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SessionNetworkingKit import SessionMessagingKit import SessionUtilitiesKit +// MARK: - Log.Category + +public extension Log.Category { + static let proSettingsViewModel: Log.Category = .create("ProSettingsViewModel", defaultLevel: .warn) +} + +// MARK: - SessionProSettingsViewModel + public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType, NavigatableStateHolder { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() @@ -18,7 +27,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType public let state: SessionListScreenContent.ListItemDataState = SessionListScreenContent.ListItemDataState() /// This value is the current state of the view - @MainActor @Published private(set) var internalState: ViewModelState + @MainActor @Published private(set) var internalState: State private var observationTask: Task? // MARK: - Initialization @@ -27,7 +36,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType using dependencies: Dependencies ) { self.dependencies = dependencies - self.internalState = ViewModelState.initialState() + self.internalState = State.initialState(using: dependencies) self.observationTask = ObservationBuilder .initialValue(self.internalState) @@ -118,16 +127,20 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Content - public struct ViewModelState: ObservableKeyProvider { + public struct State: ObservableKeyProvider { + let profile: Profile + let loadingState: SessionPro.LoadingState let numberOfGroupsUpgraded: Int let numberOfPinnedConversations: Int let numberOfProBadgesSent: Int let numberOfLongerMessagesSent: Int - let isProBadgeEnabled: Bool - let currentProPlanState: SessionProPlanState - let loadingState: SessionProLoadingState + let plans: [SessionPro.Plan] + let proStatus: Network.SessionPro.BackendUserProStatus? + let proAutoRenewing: Bool? + let proAccessExpiryTimestampMs: UInt64? + let proLatestPaymentItem: Network.SessionPro.PaymentItem? - @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: ViewModelState) -> [SectionModel] { + @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] { SessionProSettingsViewModel.sections( state: self, previousState: previousState, @@ -135,104 +148,191 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } - public let observedKeys: Set = [ - .anyConversationPinnedPriorityChanged, - .setting(.groupsUpgradedCounter), - .setting(.proBadgesSentCounter), - .setting(.longerMessagesSentCounter), - .setting(.isProBadgeEnabled), - .feature(.mockCurrentUserSessionProState), // TODO: [PRO] real data from libSession - .feature(.mockCurrentUserSessionProLoadingState) // TODO: [PRO] real loading status - ] + /// We need `dependencies` to generate the keys in this case so set the variable `observedKeys` to an empty array to + /// suppress the conformance warning + public let observedKeys: Set = [] + public func observedKeys(using dependencies: Dependencies) -> Set { + let sessionProManager: SessionProManagerType = dependencies[singleton: .sessionProManager] + + return [ + .anyConversationPinnedPriorityChanged, + .profile(profile.id), + .currentUserProLoadingState(sessionProManager), + .currentUserProStatus(sessionProManager), + .currentUserProAutoRenewing(sessionProManager), + .currentUserProAccessExpiryTimestampMs(sessionProManager), + .currentUserProLatestPaymentItem(sessionProManager), + .setting(.groupsUpgradedCounter), + .setting(.proBadgesSentCounter), + .setting(.longerMessagesSentCounter), + .feature(.mockCurrentUserSessionProLoadingState) + ] + } - static func initialState() -> ViewModelState { - return ViewModelState( + static func initialState(using dependencies: Dependencies) -> State { + return State( + profile: dependencies.mutate(cache: .libSession) { $0.profile }, + loadingState: .loading, numberOfGroupsUpgraded: 0, numberOfPinnedConversations: 0, numberOfProBadgesSent: 0, numberOfLongerMessagesSent: 0, - isProBadgeEnabled: false, - currentProPlanState: .none, - loadingState: .loading + plans: [], + proStatus: nil, + proAutoRenewing: nil, + proAccessExpiryTimestampMs: nil, + proLatestPaymentItem: nil ) } } @Sendable private static func queryState( - previousState: ViewModelState, + previousState: State, events: [ObservedEvent], isInitialQuery: Bool, using dependencies: Dependencies - ) async -> ViewModelState { + ) async -> State { + var profile: Profile = previousState.profile + var loadingState: SessionPro.LoadingState = previousState.loadingState var numberOfGroupsUpgraded: Int = previousState.numberOfGroupsUpgraded var numberOfPinnedConversations: Int = previousState.numberOfPinnedConversations var numberOfProBadgesSent: Int = previousState.numberOfProBadgesSent var numberOfLongerMessagesSent: Int = previousState.numberOfLongerMessagesSent - var isProBadgeEnabled: Bool = previousState.isProBadgeEnabled - var currentProPlanState: SessionProPlanState = previousState.currentProPlanState - var loadingState: SessionProLoadingState = previousState.loadingState + var plans: [SessionPro.Plan] = previousState.plans + var proStatus: Network.SessionPro.BackendUserProStatus? = previousState.proStatus + var proAutoRenewing: Bool? = previousState.proAutoRenewing + var proAccessExpiryTimestampMs: UInt64? = previousState.proAccessExpiryTimestampMs + var proLatestPaymentItem: Network.SessionPro.PaymentItem? = previousState.proLatestPaymentItem + + /// Store a local copy of the events so we can manipulate it based on the state changes + let eventsToProcess: [ObservedEvent] = events /// If we have no previous state then we need to fetch the initial state if isInitialQuery { - dependencies.mutate(cache: .libSession) { libSession in - isProBadgeEnabled = libSession.get(.isProBadgeEnabled) - } - dependencies[singleton: .storage].read { db in - numberOfGroupsUpgraded = db[.groupsUpgradedCounter] ?? 0 - numberOfPinnedConversations = ( - try? SessionThread - .filter(SessionThread.Columns.pinnedPriority > 0) - .fetchCount(db) + do { + loadingState = await dependencies[singleton: .sessionProManager].loadingState + .first(defaultValue: .loading) + proStatus = await dependencies[singleton: .sessionProManager].backendUserProStatus + .first(defaultValue: nil) + proAutoRenewing = await dependencies[singleton: .sessionProManager].autoRenewing + .first(defaultValue: nil) + proAccessExpiryTimestampMs = await dependencies[singleton: .sessionProManager].accessExpiryTimestampMs + .first(defaultValue: nil) + proLatestPaymentItem = await dependencies[singleton: .sessionProManager].latestPaymentItem + .first(defaultValue: nil) + + try await dependencies[singleton: .storage].readAsync { db in + numberOfGroupsUpgraded = (db[.groupsUpgradedCounter] ?? 0) + numberOfPinnedConversations = ( + try? SessionThread + .filter(SessionThread.Columns.pinnedPriority > 0) + .fetchCount(db) ).defaulting(to: 0) - numberOfProBadgesSent = db[.proBadgesSentCounter] ?? 0 - numberOfLongerMessagesSent = db[.longerMessagesSentCounter] ?? 0 + numberOfProBadgesSent = (db[.proBadgesSentCounter] ?? 0) + numberOfLongerMessagesSent = (db[.longerMessagesSentCounter] ?? 0) + } + } + catch { + Log.critical(.proSettingsViewModel, "Failed to fetch initial state, due to error: \(error)") } } - /// Process any event changes - events.forEach { event in + /// Always try to get plans if they are empty + if plans.isEmpty { + plans = await dependencies[singleton: .sessionProManager].plans + } + + /// Split the events between those that need database access and those that don't + let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) + + /// Process any general event changes + switch (dependencies[feature: .mockCurrentUserSessionProLoadingState], changes.latest(.currentUserProLoadingState, as: SessionPro.LoadingState.self)) { + case (.simulate(let mockedState), _): loadingState = mockedState + case (.useActual, .some(let updatedValue)): loadingState = updatedValue + default: break + } + + switch (dependencies[feature: .mockCurrentUserSessionProBackendStatus], changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self)) { + case (.simulate(let mockedState), _): proStatus = mockedState + case (.useActual, .some(let updatedValue)): proStatus = updatedValue + default: break + } + + if let value = changes.latest(.currentUserProAutoRenewing, as: Bool.self) { + proAutoRenewing = value + } + + if let value = changes.latest(.currentUserProAccessExpiryTimestampMs, as: UInt64.self) { + proAccessExpiryTimestampMs = value + } + + if let value = changes.latest(.currentUserProLatestPaymentItem, as: Network.SessionPro.PaymentItem.self) { + proLatestPaymentItem = value + } + + changes.forEach(.profile, as: ProfileEvent.self) { event in + switch event.change { + case .name(let name): profile = profile.with(name: name) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let expiryUnixTimestampMs, let genIndexHashHex): + profile = profile.with( + proFeatures: .set(to: features), + proExpiryUnixTimestampMs: .set(to: expiryUnixTimestampMs), + proGenIndexHashHex: .set(to: genIndexHashHex) + ) + default: break + } + } + + changes.forEachEvent(.setting, as: Int.self) { event, value in switch event.key { - case .anyConversationPinnedPriorityChanged: - dependencies[singleton: .storage].read { db in + case .setting(.groupsUpgradedCounter): numberOfGroupsUpgraded = value + case .setting(.proBadgesSentCounter): numberOfProBadgesSent = value + case .setting(.longerMessagesSentCounter): numberOfLongerMessagesSent = value + default: break + } + } + + /// Then handle database events + if !dependencies[singleton: .storage].isSuspended, !changes.databaseEvents.isEmpty { + do { + try await dependencies[singleton: .storage].readAsync { db in + if changes.latest(.anyConversationPinnedPriorityChanged) != nil { numberOfPinnedConversations = ( try? SessionThread .filter(SessionThread.Columns.pinnedPriority > 0) .fetchCount(db) ).defaulting(to: 0) } - case .setting(.groupsUpgradedCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfGroupsUpgraded = updatedValue - case .setting(.proBadgesSentCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfProBadgesSent = updatedValue - case .setting(.longerMessagesSentCounter): - guard let updatedValue = event.value as? Int else { return } - numberOfLongerMessagesSent = updatedValue - case .setting(.isProBadgeEnabled): - guard let updatedValue = event.value as? Bool else { return } - isProBadgeEnabled = updatedValue - default: break + } + } catch { + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.proSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } + else if !changes.databaseEvents.isEmpty { + Log.warn(.proSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } - currentProPlanState = dependencies[singleton: .sessionProState].sessionProStateSubject.value - loadingState = dependencies[feature: .mockCurrentUserSessionProLoadingState] - - return ViewModelState( + return State( + profile: profile, + loadingState: loadingState, numberOfGroupsUpgraded: numberOfGroupsUpgraded, numberOfPinnedConversations: numberOfPinnedConversations, numberOfProBadgesSent: numberOfProBadgesSent, numberOfLongerMessagesSent: numberOfLongerMessagesSent, - isProBadgeEnabled: isProBadgeEnabled, - currentProPlanState: currentProPlanState, - loadingState: loadingState + plans: plans, + proStatus: proStatus, + proAutoRenewing: proAutoRenewing, + proAccessExpiryTimestampMs: proAccessExpiryTimestampMs, + proLatestPaymentItem: proLatestPaymentItem ) } private static func sections( - state: ViewModelState, - previousState: ViewModelState, + state: State, + previousState: State, viewModel: SessionProSettingsViewModel ) -> [SectionModel] { let logo: SectionModel = SectionModel( @@ -241,15 +341,15 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .logoWithPro, variant: .logoWithPro( - info: .init( + info: ListItemLogoWithPro.Info( style:{ - switch state.currentProPlanState { + switch state.proStatus { case .expired: .disabled default: .normal } }(), state: { - guard state.currentProPlanState != .none else { + guard state.proStatus != .none else { return .success( description: "proFullestPotential" .put(key: "app_name", value: Constants.app_name) @@ -259,14 +359,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } switch state.loadingState { + case .success: return .success(description: nil) case .loading: return .loading( message: { - switch state.currentProPlanState { + switch state.proStatus { case .expired: "checkingProStatus" .put(key: "pro", value: Constants.pro) .localized() + default: "proStatusLoading" .put(key: "pro", value: Constants.pro) @@ -274,14 +376,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }() ) + case .error: return .error( message: { - switch state.currentProPlanState { + switch state.proStatus { case .expired: "errorCheckingProStatus" .put(key: "pro", value: Constants.pro) .localized() + default: "proErrorRefreshingStatus" .put(key: "pro", value: Constants.pro) @@ -289,23 +393,23 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }() ) - case .success: - return .success(description: nil) } }() ) ), onTap: { [weak viewModel] in switch state.loadingState { + case .success: break case .loading: viewModel?.showLoadingModal( from: .logoWithPro, title: { - switch state.currentProPlanState { - case .active, .refunding, .none: + switch state.proStatus { + case .active, .neverBeenPro, .none://.refunding, .none: // TODO: [PRO] Add in "refunding" status "proStatusLoading" .put(key: "pro", value: Constants.pro) .localized() + case .expired: "checkingProStatus" .put(key: "pro", value: Constants.pro) @@ -313,11 +417,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }(), description: { - switch state.currentProPlanState { - case .active, .refunding, .none: + switch state.proStatus { + case .active, .neverBeenPro, .none://.refunding, .none: // TODO: [PRO] Add in "refunding" status "proStatusLoadingDescription" .put(key: "pro", value: Constants.pro) .localized() + case .expired: "checkingProStatusDescription" .put(key: "pro", value: Constants.pro) @@ -325,6 +430,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }() ) + case .error: viewModel?.showErrorModal( from: .logoWithPro, @@ -335,17 +441,15 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localizedFormatted() ) - case .success: - break } } ), ( - state.currentProPlanState != .none ? nil : + state.proStatus != .none ? nil : SessionListScreenContent.ListItemInfo( id: .continueButton, variant: .button(title: "theContinue".localized()), - onTap: { [weak viewModel] in viewModel?.updateProPlan() } + onTap: { [weak viewModel] in viewModel?.updateProPlan(state: state) } ) ) ].compactMap { $0 } @@ -359,13 +463,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType variant: .dataMatrix( info: [ [ - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .messageSquare, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proLongerMessagesSent" .putNumber(state.numberOfLongerMessagesSent) .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) @@ -374,13 +478,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), isLoading: state.loadingState == .loading ), - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .pin, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proPinnedConversations" .putNumber(state.numberOfPinnedConversations) .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfPinnedConversations) @@ -391,13 +495,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ], [ - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( .rectangleEllipsis, size: .large, customTint: .primary ), - title: .init( + title: SessionListScreenContent.TextInfo( "proBadgesSent" .putNumber(state.numberOfProBadgesSent) .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfProBadgesSent) @@ -407,13 +511,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), isLoading: state.loadingState == .loading ), - .init( + ListItemDataMatrix.Info( leadingAccessory: .icon( UIImage(named: "ic_user_group"), size: .large, customTint: .disabled ), - title: .init( + title: SessionListScreenContent.TextInfo( "proGroupsUpgraded" .putNumber(state.numberOfGroupsUpgraded) .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) @@ -421,7 +525,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType font: .Headings.H9, color: state.loadingState == .loading ? .textPrimary : .disabled ), - tooltipInfo: .init( + tooltipInfo: SessionListScreenContent.TooltipInfo( id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore content: "proLargerGroupsTooltip" .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)), @@ -435,6 +539,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), onTap: { [weak viewModel] in guard state.loadingState == .loading else { return } + viewModel?.showLoadingModal( from: .proStats, title: "proStatsLoading" @@ -456,7 +561,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let proFeatures: SectionModel = SectionModel( model: .proFeatures, - elements: ProFeaturesInfo.allCases(state.currentProPlanState).map { info in + elements: ProFeaturesInfo.allCases(state.proStatus).map { info in SessionListScreenContent.ListItemInfo( id: info.id, variant: .cell( @@ -484,7 +589,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType iconSize: .medium, customTint: .black, gradientBackgroundColors: { - return switch state.currentProPlanState { + return switch state.proStatus { case .expired: [ThemeValue.disabled] default: [.explicitPrimary(.orange), .explicitPrimary(.yellow)] } @@ -536,7 +641,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.currentProPlanState { + switch state.proStatus { case .expired: return .textPrimary default: return .sessionButton_text } @@ -564,7 +669,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.currentProPlanState { + switch state.proStatus { case .expired: return .textPrimary default: return .sessionButton_text } @@ -577,35 +682,30 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ] ) - return switch state.currentProPlanState { - case .none: - [ logo, proFeatures, help ] - case .active: - [ logo, proStats, proSettings, proFeatures, proManagement, help ] - case .expired: - [ logo, proManagement, proFeatures, help ] - case .refunding: - [ logo, proStats, proSettings, proFeatures, help ] + return switch state.proStatus { + case .none, .neverBeenPro: [ logo, proFeatures, help ] + case .active: [ logo, proStats, proSettings, proFeatures, proManagement, help ] + case .expired: [ logo, proManagement, proFeatures, help ] } } // MARK: - Pro Settings Elements private static func getProSettingsElements( - state: ViewModelState, - previousState: ViewModelState, + state: State, + previousState: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { return [ { - switch state.currentProPlanState { - case .none: nil - case .active(_, let expiredOn, let isAutoRenewing, _): + switch state.proStatus { + case .none, .neverBeenPro, .expired: nil + case .active: SessionListScreenContent.ListItemInfo( id: .updatePlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "updateAccess" .put(key: "pro", value: Constants.pro) .localized(), @@ -614,32 +714,44 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType description: { switch state.loadingState { case .loading: - .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "proAccessLoadingEllipsis" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular) ) + case .error: - .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "errorLoadingProAccess" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular), color: .warning ) + case .success: - .init( + let expirationDate: Date = Date( + timeIntervalSince1970: floor(Double(state.proAccessExpiryTimestampMs ?? 0) / 1000) + ) + let expirationString: String = expirationDate + .timeIntervalSince(viewModel.dependencies.dateNow) + .ceilingFormatted( + format: .long, + allowedUnits: [.day, .hour, .minute] + ) + + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: ( - isAutoRenewing ? + state.proAutoRenewing == true ? "proAutoRenewTime" .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expiredOn.timeIntervalSinceNow.ceilingFormatted(format: .long, allowedUnits: [.day, .hour, .minute])) + .put(key: "time", value: expirationString) .localizedFormatted(Fonts.Body.smallRegular) : "proExpiringTime" .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expiredOn.timeIntervalSinceNow.ceilingFormatted(format: .long, allowedUnits: [.day, .hour, .minute])) + .put(key: "time", value: expirationString) .localizedFormatted(Fonts.Body.smallRegular) ) ) @@ -650,6 +762,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), onTap: { [weak viewModel] in switch state.loadingState { + case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( from: .updatePlan, @@ -660,6 +773,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized() ) + case .error: viewModel?.showErrorModal( from: .updatePlan, @@ -671,53 +785,6 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "app_name", value: Constants.app_name) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ) - case .success: - viewModel?.updateProPlan() - } - } - ) - case .expired: - nil - case .refunding(let originatingPlatform, _): - SessionListScreenContent.ListItemInfo( - id: .refundRequested, - variant: .cell( - info: .init( - title: .init("proRequestedRefund".localized(), font: .Headings.H8), - description: .init( - font: .Body.smallRegular, - attributedString: "processingRefundRequest" - .put(key: "platform", value: originatingPlatform.name) - .localizedFormatted(Fonts.Body.smallRegular) - ), - trailingAccessory: .icon(.circleAlert, size: .large) - ) - ), - onTap: { [weak viewModel] in - switch state.loadingState { - case .loading: - viewModel?.showLoadingModal( - from: .updatePlan, - title: "proAccessLoading" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessLoadingDescription" - .put(key: "pro", value: Constants.pro) - .localized() - ) - case .error: - viewModel?.showErrorModal( - from: .updatePlan, - title: "proAccessError" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessNetworkLoadError" - .put(key: "pro", value: Constants.pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ) - case .success: - viewModel?.updateProPlan() } } ) @@ -726,17 +793,35 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .proBadge, variant: .cell( - info: .init( - title: .init("proBadge".put(key: "pro", value: Constants.pro).localized(), font: .Headings.H8), - description: .init("proBadgeVisible".put(key: "app_pro", value: Constants.app_pro).localized(), font: .Body.smallRegular), + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "proBadge" + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + "proBadgeVisible" + .put(key: "app_pro", value: Constants.app_pro) + .localized(), + font: .Body.smallRegular + ), trailingAccessory: .toggle( - state.isProBadgeEnabled, - oldValue: previousState.isProBadgeEnabled + state.profile.proFeatures.contains(.proBadge), + oldValue: previousState.profile.proFeatures.contains(.proBadge) ) ) ), onTap: { [dependencies = viewModel.dependencies] in - dependencies.setAsync(.isProBadgeEnabled, !state.isProBadgeEnabled) + Task.detached(priority: .userInitiated) { + try? await Profile.updateLocal( + proFeatures: (state.profile.proFeatures.contains(.proBadge) ? + state.profile.proFeatures.removing(.proBadge) : + state.profile.proFeatures.inserting(.proBadge) + ), + using: dependencies + ) + } } ) ].compactMap { $0 } @@ -745,19 +830,19 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType // MARK: - Pro Management Elements private static func getProManagementElements( - state: ViewModelState, + state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - return switch state.currentProPlanState { - case .none: [] - case .active(_, _, let isAutoRenewing, _): + return switch state.proStatus { + case .none, .neverBeenPro: [] + case .active: [ - !isAutoRenewing ? nil : + state.proAutoRenewing != true ? nil : SessionListScreenContent.ListItemInfo( id: .cancelPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "cancelAccess" .put(key: "pro", value: Constants.pro) .localized(), @@ -767,26 +852,31 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType trailingAccessory: .icon(.circleX, size: .large, customTint: .danger) ) ), - onTap: { [weak viewModel] in viewModel?.cancelPlan() } + onTap: { [weak viewModel] in viewModel?.cancelPlan(state: state) } ), SessionListScreenContent.ListItemInfo( id: .requestRefund, variant: .cell( - info: .init( - title: .init("requestRefund".localized(), font: .Headings.H8, color: .danger), + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "requestRefund".localized(), + font: .Headings.H8, + color: .danger + ), trailingAccessory: .icon(.circleAlert, size: .large, customTint: .danger) ) ), - onTap: { [weak viewModel] in viewModel?.requestRefund() } + onTap: { [weak viewModel] in viewModel?.requestRefund(state: state) } ) ].compactMap { $0 } + case .expired: [ SessionListScreenContent.ListItemInfo( id: .renewPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "proAccessRenew" .put(key: "pro", value: Constants.pro) .localized(), @@ -795,24 +885,24 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), description: { switch state.loadingState { + case .success: return nil case .error: - return .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "errorCheckingProStatus" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular), color: .warning ) + case .loading: - return .init( + return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "checkingProStatusEllipsis" .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.smallRegular), color: .textPrimary ) - case .success: - return nil } }(), trailingAccessory: ( @@ -828,6 +918,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), onTap: { [weak viewModel] in switch state.loadingState { + case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( from: .renewPlan, @@ -838,6 +929,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized() ) + case .error: viewModel?.showErrorModal( from: .updatePlan, @@ -849,16 +941,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "app_name", value: Constants.app_name) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ) - case .success: - viewModel?.updateProPlan() } } ), SessionListScreenContent.ListItemInfo( id: .recoverPlan, variant: .cell( - info: .init( - title: .init( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( "proAccessRecover" .put(key: "pro", value: Constants.pro) .localized(), @@ -875,7 +965,6 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType onTap: { [weak viewModel] in viewModel?.recoverProPlan() } ), ] - case .refunding: [] } } } @@ -946,11 +1035,10 @@ extension SessionProSettingsViewModel { confirmStyle: .alert_text, cancelTitle: "helpSupport".localized(), cancelStyle: .alert_text, - onConfirm: { [dependencies = self.dependencies] _ in - dependencies.set( - feature: .mockCurrentUserSessionProLoadingState, - to: .loading - ) + onConfirm: { [dependencies] _ in + Task.detached(priority: .userInitiated) { + try? await dependencies[singleton: .sessionProManager].refreshProState() + } }, onCancel: { [weak self] _ in self?.openUrl(Constants.session_pro_support_url) @@ -961,14 +1049,20 @@ extension SessionProSettingsViewModel { self.transitionToScreen(modal, transitionType: .present) } - func updateProPlan() { + @MainActor func updateProPlan(state: State) { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dependencies: dependencies, - dataModel: .init( - flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + dataModel: SessionProPaymentScreenContent.DataModel( + flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow( + plans: state.plans, + proStatus: state.proStatus, + autoRenewing: state.proAutoRenewing, + accessExpiryTimestampMs: state.proAccessExpiryTimestampMs, + latestPaymentItem: state.proLatestPaymentItem + ), + plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ) ) ) @@ -976,66 +1070,25 @@ extension SessionProSettingsViewModel { self.transitionToScreen(viewController) } - func recoverProPlan() { - dependencies[singleton: .sessionProState].recoverPro { [weak self] result in - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: ( - result ? - "proAccessRestored" - .put(key: "pro", value: Constants.pro) - .localized() : - "proAccessNotFound" - .put(key: "pro", value: Constants.pro) - .localized() - ), - body: .text( - ( - result ? - "proAccessRestoredDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() : - "proAccessNotFoundDescription" - .put(key: "app_name", value: Constants.app_name) - .put(key: "pro", value: Constants.pro) - .localized() - ), - scrollMode: .never - ), - confirmTitle: (result ? nil : "helpSupport".localized()), - cancelTitle: (result ? "okay".localized() : "close".localized()), - cancelStyle: (result ? .textPrimary : .danger), - dismissOnConfirm: false, - onConfirm: { [weak self] modal in - guard result == false else { - return modal.dismiss(animated: true) - } - - self?.openUrl(Constants.session_pro_recovery_support_url) - } - ) - ) - - self?.transitionToScreen(modal, transitionType: .present) - } + @MainActor func recoverProPlan() { } - func cancelPlan() { + func cancelPlan(state: State) { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dependencies: dependencies, - dataModel: .init( + dataModel: SessionProPaymentScreenContent.DataModel( flow: .cancel( originatingPlatform: { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android + switch state.proLatestPaymentItem?.paymentProvider { + case .none: return .iOS /// Should default to iOS on iOS devices + case .appStore: return .iOS + case .playStore: return .android } }() ), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ) ) ) @@ -1043,22 +1096,23 @@ extension SessionProSettingsViewModel { self.transitionToScreen(viewController) } - func requestRefund() { + func requestRefund(state: State) { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dependencies: dependencies, - dataModel: .init( + dataModel: SessionProPaymentScreenContent.DataModel( flow: .refund( originatingPlatform: { - switch dependencies[singleton: .sessionProState].sessionProStateSubject.value.originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android + switch state.proLatestPaymentItem?.paymentProvider { + case .none: return .iOS /// Should default to iOS on iOS devices + case .appStore: return .iOS + case .playStore: return .android } }(), requestedAt: nil ), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } + plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ) ) ) @@ -1078,7 +1132,7 @@ extension SessionProSettingsViewModel { let description: String let accessory: SessionListScreenContent.TextInfo.Accessory - static func allCases(_ state: SessionProPlanState) -> [ProFeaturesInfo] { + static func allCases(_ state: Network.SessionPro.BackendUserProStatus?) -> [ProFeaturesInfo] { return [ ProFeaturesInfo( id: .longerMessages, @@ -1146,101 +1200,106 @@ extension SessionProSettingsViewModel { // MARK: - Convenience -extension SessionProPlan { - func info() -> SessionProPaymentScreenContent.SessionProPlanInfo { - let price: Double = self.variant.price - let pricePerMonth: Double = (self.variant.price / Double(self.variant.duration)) - return .init( - duration: self.variant.duration, +extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { + init( + plans: [SessionPro.Plan], + proStatus: Network.SessionPro.BackendUserProStatus?, + autoRenewing: Bool?, + accessExpiryTimestampMs: UInt64?, + latestPaymentItem: Network.SessionPro.PaymentItem? + ) { + let latestPlan: SessionPro.Plan? = plans.first { $0.variant == latestPaymentItem?.plan } + let expiryDate: Date? = accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } + + switch (proStatus, latestPlan) { + case (.none, _), (.neverBeenPro, _), (.active, .none): self = .purchase + case (.active, .some(let plan)): + self = .update( + currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), + expiredOn: (expiryDate ?? Date.distantPast), + isAutoRenewing: (autoRenewing == true), + originatingPlatform: { + switch latestPaymentItem?.paymentProvider { + case .none: return .iOS /// Should default to iOS on iOS devices + case .appStore: return .iOS + case .playStore: return .android + } + }() + ) + + case (.expired, _): + self = .renew( + originatingPlatform: { + switch latestPaymentItem?.paymentProvider { + case .none: return .iOS /// Should default to iOS on iOS devices + case .appStore: return .iOS + case .playStore: return .android + } + }() + ) + } + } +} + +extension SessionProPaymentScreenContent.SessionProPlanInfo { + init(plan: SessionPro.Plan) { + let price: Double = Double(truncating: plan.price as NSNumber) + let pricePerMonth: Double = Double(truncating: plan.pricePerMonth as NSNumber) + let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true)) + let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true)) + + self = SessionProPaymentScreenContent.SessionProPlanInfo( + duration: plan.durationMonths, totalPrice: price, pricePerMonth: pricePerMonth, - discountPercent: self.variant.discountPercent, + discountPercent: plan.discountPercent, titleWithPrice: { - switch self.variant { - case .oneMonth: + switch plan.variant { + case .none, .oneMonth: return "proPriceOneMonth" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "monthly_price", value: formattedPricePerMonth) .localized() + case .threeMonths: return "proPriceThreeMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "monthly_price", value: formattedPricePerMonth) .localized() + case .twelveMonths: return "proPriceTwelveMonths" - .put(key: "monthly_price", value: pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "monthly_price", value: formattedPricePerMonth) .localized() } }(), subtitleWithPrice: { - switch self.variant { - case .oneMonth: + switch plan.variant { + case .none, .oneMonth: return "proBilledMonthly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "price", value: formattedPrice) .localized() + case .threeMonths: return "proBilledQuarterly" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "price", value: formattedPrice) .localized() + case .twelveMonths: return "proBilledAnnually" - .put(key: "price", value: price.formatted(format: .currency(decimal: true, withLocalSymbol: true))) + .put(key: "price", value: formattedPrice) .localized() } }() ) } - - static func from(_ info: SessionProPaymentScreenContent.SessionProPlanInfo) -> SessionProPlan { - let variant: SessionProPlan.Variant = { - switch info.duration { - case 1: return .oneMonth - case 3: return .threeMonths - case 12: return .twelveMonths - default: fatalError("Unhandled SessionProPlan.Variant.Duration case") - } - }() - - return SessionProPlan(variant: variant) - } } -extension SessionProPlanState { - func toPaymentFlow() -> SessionProPaymentScreenContent.SessionProPlanPaymentFlow { - switch self { - case .none: - return .purchase - case .active(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): - return .update( - currentPlan: currentPlan.info(), - expiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }() - ) - case .expired(_, let originatingPlatform): - return .renew( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }() - ) - case .refunding(let originatingPlatform, let requestedAt): - return .refund( - originatingPlatform: { - switch originatingPlatform { - case .iOS: return .iOS - case .Android: return .Android - } - }(), - requestedAt: requestedAt - ) +// MARK: - Convenience + +private extension ObservedEvent { + var dataRequirement: EventDataRequirement { + switch (key, key.generic) { + case (.anyConversationPinnedPriorityChanged, _): return .databaseQuery + default: return .other } } } - diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 72e6535ba7..0858053e64 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -167,15 +167,21 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl SettingsViewModel.sections(state: self, viewModel: viewModel) } - public var observedKeys: Set { - [ + /// We need `dependencies` to generate the keys in this case so set the variable `observedKeys` to an empty array to + /// suppress the conformance warning + public let observedKeys: Set = [] + public func observedKeys(using dependencies: Dependencies) -> Set { + let sessionProManager: SessionProManagerType = dependencies[singleton: .sessionProManager] + + return [ .profile(userSessionId.hexString), .feature(.mockCurrentUserSessionProBackendStatus), .feature(.serviceNetwork), .feature(.forceOffline), .setting(.developerModeEnabled), - .setting(.hideRecoveryPasswordPermanently) - // TODO: [PRO] Need to observe changes to the users pro status + .setting(.hideRecoveryPasswordPermanently), + .currentUserProLoadingState(sessionProManager), + .currentUserProStatus(sessionProManager), ] } @@ -229,13 +235,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isInitialFetch { serviceNetwork = dependencies[feature: .serviceNetwork] forceOffline = dependencies[feature: .forceOffline] - - if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { - switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { - case .useActual: break - case .simulate(let value): sessionProBackendStatus = value - } - } + sessionProBackendStatus = await dependencies[singleton: .sessionProManager].backendUserProStatus + .first(defaultValue: nil) dependencies.mutate(cache: .libSession) { libSession in profile = libSession.profile @@ -244,13 +245,14 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + /// Split the events + let changes: EventChangeset = events.split() + /// If the device has a mock pro status set then use that - if dependencies.hasSet(feature: .mockCurrentUserSessionProBackendStatus) { - // TODO: [PRO] When observing actual status change events we will need to check if this has been mocked or not and set the appropriate value (might actually be best to handle this as the feature change event instead) - switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { - case .useActual: break - case .simulate(let value): sessionProBackendStatus = value - } + switch (dependencies[feature: .mockCurrentUserSessionProBackendStatus], changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self)) { + case (.simulate(let mockedState), _): sessionProBackendStatus = mockedState + case (.useActual, .some(let updatedValue)): sessionProBackendStatus = updatedValue + default: break } /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading @@ -358,7 +360,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl trailingImage: { switch state.sessionProBackendStatus { case .none, .neverBeenPro: return nil - case .active, .refunding: + case .active: return ( .themedKey( SessionProBadge.Size.medium.cacheKey, diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index fdb192def2..4fdf64cb74 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -27,6 +27,7 @@ final class ThemeMessagePreviewView: UIView { shouldExpanded: false, lastSearchText: nil, tableSize: UIScreen.main.bounds.size, + displayNameRetriever: { _, _ in nil }, using: dependencies ) cell.contentHStack.removeFromSuperview() @@ -49,6 +50,7 @@ final class ThemeMessagePreviewView: UIView { shouldExpanded: false, lastSearchText: nil, tableSize: UIScreen.main.bounds.size, + displayNameRetriever: { _, _ in nil }, using: dependencies ) cell.contentHStack.removeFromSuperview() diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift index e818ecac4e..5f5fab7960 100644 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -22,26 +22,4 @@ public extension MentionUtilities { ) ) } - - static func highlightMentions( - in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - using dependencies: Dependencies - ) -> ThemedAttributedString { - return MentionUtilities.highlightMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - location: location, - textColor: textColor, - attributes: attributes, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ) - ) - } } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 989618c11d..a632dcaef0 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -406,11 +406,9 @@ public extension UIContextualAction { switch threadViewModel.threadVariant { case .group: return Profile.displayName( - for: .contact, id: profileInfo.id, name: profileInfo.profile?.name, - nickname: profileInfo.profile?.nickname, - suppressId: false + nickname: profileInfo.profile?.nickname ) default: return threadViewModel.displayName diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index eba4035727..45faa59b57 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -91,10 +91,8 @@ extension Contact: ProfileAssociated { public var profileId: String { id } public static func compare(lhs: WithProfile, rhs: WithProfile) -> Bool { - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased()) } diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 8fc4c52cbb..4a9b70bc9b 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -124,10 +124,8 @@ extension GroupMember: ProfileAssociated { rhs: WithProfile ) -> Bool { let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group) - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) // Legacy groups have a different sorting behaviour guard isUpdatedGroup else { @@ -207,10 +205,8 @@ extension GroupMember: ProfileAssociated { rhs: WithProfile ) -> Bool { let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group) - let lhsDisplayName: String = (lhs.profile?.displayName(for: .contact)) - .defaulting(to: lhs.profileId.truncated(threadVariant: .contact)) - let rhsDisplayName: String = (rhs.profile?.displayName(for: .contact)) - .defaulting(to: rhs.profileId.truncated(threadVariant: .contact)) + let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated()) + let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated()) // Legacy groups have a different sorting behaviour guard isUpdatedGroup else { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 320673c8d0..3b9d35c89e 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -200,12 +200,9 @@ public extension Profile { static func displayName( _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, customFallback: String? = nil ) -> String { - let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant, suppressId: suppressId) + let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))?.displayName() return (existingDisplayName ?? (customFallback ?? id)) } @@ -216,8 +213,7 @@ public extension Profile { threadVariant: SessionThread.Variant = .contact, suppressId: Bool = false ) -> String? { - return (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant, suppressId: suppressId) + return (try? Profile.fetchOne(db, id: id))?.displayName() } // MARK: - Fetch or Create @@ -255,15 +251,13 @@ public extension Profile { @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") static func displayName( id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, customFallback: String? = nil, using dependencies: Dependencies ) -> String { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var displayName: String? dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, + retrieve: { db in Profile.displayName(db, id: id) }, completion: { result in switch result { case .failure: break @@ -303,7 +297,7 @@ public extension Profile { static func defaultDisplayNameRetriever( threadVariant: SessionThread.Variant = .contact, using dependencies: Dependencies - ) -> ((String, Bool) -> String?) { + ) -> (DisplayNameRetriever) { // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) return { sessionId, _ in Profile.displayNameNoFallback( @@ -334,54 +328,53 @@ public extension Profile { // MARK: - Convenience public extension Profile { - func displayNameForMention( - for threadVariant: SessionThread.Variant = .contact, - ignoringNickname: Bool = false, - currentUserSessionIds: Set = [] - ) -> String { - guard !currentUserSessionIds.contains(id) else { - return "you".localized() - } - return displayName(for: threadVariant, ignoringNickname: ignoringNickname) - } - /// The name to display in the UI for a given thread variant func displayName( - for threadVariant: SessionThread.Variant = .contact, messageProfile: VisibleMessage.VMProfile? = nil, - ignoringNickname: Bool = false, - suppressId: Bool = false + ignoreNickname: Bool = false, + showYouForCurrentUser: Bool = true, + currentUserSessionIds: Set = [], + includeSessionIdSuffix: Bool = false ) -> String { return Profile.displayName( - for: threadVariant, id: id, name: (messageProfile?.displayName?.nullIfEmpty ?? name), - nickname: (ignoringNickname ? nil : nickname), - suppressId: suppressId + nickname: (ignoreNickname ? nil : nickname), + showYouForCurrentUser: showYouForCurrentUser, + currentUserSessionIds: currentUserSessionIds, + includeSessionIdSuffix: includeSessionIdSuffix ) } static func displayName( - for threadVariant: SessionThread.Variant, id: String, name: String?, nickname: String?, - suppressId: Bool, + showYouForCurrentUser: Bool = true, + currentUserSessionIds: Set = [], + includeSessionIdSuffix: Bool = false, customFallback: String? = nil ) -> String { - if let nickname: String = nickname, !nickname.isEmpty { return nickname } - - guard let name: String = name, name != id, !name.isEmpty else { - return (customFallback ?? id.truncated(threadVariant: threadVariant)) + if showYouForCurrentUser && currentUserSessionIds.contains(id) { + return "you".localized() } - switch (threadVariant, suppressId) { - case (.contact, _), (.legacyGroup, _), (.group, _), (.community, true): return name + // stringlint:ignore_contents + switch (nickname, name, customFallback, includeSessionIdSuffix) { + case (.some(let value), _, _, false) where !value.isEmpty, + (_, .some(let value), _, false) where !value.isEmpty, + (_, _, .some(let value), false) where !value.isEmpty: + return value - case (.community, false): - // In open groups, where it's more likely that multiple users have the same name, - // we display a bit of the Session ID after a user's display name for added context - return "\(name) (\(id.truncated()))" + case (.some(let value), _, _, true) where !value.isEmpty, + (_, .some(let value), _, true) where !value.isEmpty, + (_, _, .some(let value), true) where !value.isEmpty: + return (Dependencies.isRTL ? + "(\(id.truncated(prefix: 4, suffix: 4))) \(value)" : + "​\(value) (\(id.truncated(prefix: 4, suffix: 4)))" + ) + + default: return id.truncated(prefix: 4, suffix: 4) } } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 22637a17ef..4852b7b42b 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -819,7 +819,7 @@ public extension SessionThread { closedGroupName: String?, openGroupName: String?, isNoteToSelf: Bool, - ignoringNickname: Bool, + ignoreNickname: Bool, profile: Profile? ) -> String { switch variant { @@ -829,7 +829,7 @@ public extension SessionThread { guard !isNoteToSelf else { return "noteToSelf".localized() } guard let profile: Profile = profile else { return threadId.truncated() } - return profile.displayName(ignoringNickname: ignoringNickname) + return profile.displayName(ignoreNickname: ignoreNickname) } } @@ -876,12 +876,3 @@ public extension SessionThread { } } } - -// 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) - } -} diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index 32dda769c6..4085974d53 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -153,7 +153,7 @@ public enum GroupInviteMemberJob: JobExecutor { public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> ThemedAttributedString { let memberZeroName: String = memberIds.first - .map { profileInfo[$0]?.displayName(for: .group) ?? $0.truncated() } + .map { profileInfo[$0]?.displayName() ?? $0.truncated() } .defaulting(to: "anonymous".localized()) switch memberIds.count { @@ -165,7 +165,7 @@ public enum GroupInviteMemberJob: JobExecutor { case 2: let memberOneName: String = ( - profileInfo[memberIds[1]]?.displayName(for: .group) ?? + profileInfo[memberIds[1]]?.displayName() ?? memberIds[1].truncated() ) @@ -259,7 +259,7 @@ public extension GroupInviteMemberJob { } let sortedFailedMemberIds: [String] = failedMemberIds.sorted { lhs, rhs in // Sort by name, followed by id if names aren't present - switch (profileMap[lhs]?.displayName(for: .group), profileMap[rhs]?.displayName(for: .group)) { + switch (profileMap[lhs]?.displayName(), profileMap[rhs]?.displayName()) { case (.some(let lhsName), .some(let rhsName)): return lhsName < rhsName case (.some, .none): return true case (.none, .some): return false diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index 8beb05cc31..b63bc7dc60 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -152,7 +152,7 @@ public enum GroupPromoteMemberJob: JobExecutor { public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> ThemedAttributedString { let memberZeroName: String = memberIds.first - .map { profileInfo[$0]?.displayName(for: .group) ?? $0.truncated() } + .map { profileInfo[$0]?.displayName() ?? $0.truncated() } .defaulting(to: "anonymous".localized()) switch memberIds.count { @@ -164,7 +164,7 @@ public enum GroupPromoteMemberJob: JobExecutor { case 2: let memberOneName: String = ( - profileInfo[memberIds[1]]?.displayName(for: .group) ?? + profileInfo[memberIds[1]]?.displayName() ?? memberIds[1].truncated() ) @@ -260,7 +260,7 @@ public extension GroupPromoteMemberJob { } let sortedFailedMemberIds: [String] = failedMemberIds.sorted { lhs, rhs in // Sort by name, followed by id if names aren't present - switch (profileMap[lhs]?.displayName(for: .group), profileMap[rhs]?.displayName(for: .group)) { + switch (profileMap[lhs]?.displayName(), profileMap[rhs]?.displayName()) { case (.some(let lhsName), .some(let rhsName)): return lhsName < rhsName case (.some, .none): return true case (.none, .some): return false diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index df8cf95195..f735449090 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -512,7 +512,7 @@ public extension LibSession.Cache { closedGroupName: finalClosedGroupName, openGroupName: finalOpenGroupName, isNoteToSelf: (threadId == userSessionId.hexString), - ignoringNickname: false, + ignoreNickname: false, profile: finalProfile ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 19e2f13748..a5aadd2b3d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -70,7 +70,7 @@ internal extension LibSessionCacheType { else { return .none } let features: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) - let status: SessionPro.ProStatus = dependencies[singleton: .sessionProManager].proStatus( + let status: SessionPro.DecodedStatus = dependencies[singleton: .sessionProManager].proStatus( for: proConfig.proProof, verifyPubkey: rotatingKeyPair.publicKey, atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -254,6 +254,12 @@ public extension LibSession.Cache { return SessionPro.ProConfig(cProConfig) } + var proAccessExpiryTimestampMs: UInt64 { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return 0 } + + return user_profile_get_pro_access_expiry_ms(conf) + } + /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) func updateProfile( diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index b420a8e747..3baeef6da8 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1056,6 +1056,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT var displayName: String? { get } var proConfig: SessionPro.ProConfig? { get } + var proAccessExpiryTimestampMs: UInt64 { get } /// This function should not be called outside of the `Profile.updateIfNeeded` function to avoid duplicating changes and events, /// as a result this function doesn't emit profile change events itself (use `Profile.updateLocal` instead) @@ -1339,6 +1340,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { var displayName: String? { return nil } var proConfig: SessionPro.ProConfig? { return nil } + var proAccessExpiryTimestampMs: UInt64 { return 0 } func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift index 8c42879ad3..c74499f43c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -33,7 +33,7 @@ public extension VisibleMessage { public static func fromProto(_ proto: SNProtoDataMessagePreview) -> VMLinkPreview? { guard !proto.url.isEmpty, - LinkPreview.isValidLinkUrl(proto.url) + LinkPreviewManager.isValidLinkUrl(proto.url) else { return nil } return VMLinkPreview( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 859f999b48..58a0062ef4 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -449,7 +449,7 @@ extension MessageReceiver { let names: [String] = message.memberSessionIds .sortedById(userSessionId: userSessionId) .map { id in - profiles[id]?.displayName(for: .group) ?? + profiles[id]?.displayName() ?? id.truncated() } @@ -581,7 +581,7 @@ extension MessageReceiver { .memberLeft( wasCurrentUser: (sender == dependencies[cache: .general].sessionId.hexString), name: ( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) ?? + (try? Profile.fetchOne(db, id: sender)?.displayName()) ?? sender.truncated() ) ) @@ -970,8 +970,8 @@ extension MessageReceiver { case .none: return ClosedGroup.MessageInfo .invited( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) - .defaulting(to: sender.truncated(threadVariant: .group)), + (try? Profile.fetchOne(db, id: sender)?.displayName()) + .defaulting(to: sender.truncated()), groupName ) .infoString(using: dependencies) @@ -979,8 +979,8 @@ extension MessageReceiver { case .some: return ClosedGroup.MessageInfo .invitedAdmin( - (try? Profile.fetchOne(db, id: sender)?.displayName(for: .group)) - .defaulting(to: sender.truncated(threadVariant: .group)), + (try? Profile.fetchOne(db, id: sender)?.displayName()) + .defaulting(to: sender.truncated()), groupName ) .infoString(using: dependencies) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index fd779c9794..36b97d1df6 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -720,7 +720,7 @@ extension MessageReceiver { /// Extract the features used for the message let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features(for: text) - let proStatus: SessionPro.ProStatus = dependencies[singleton: .sessionProManager].proStatus( + let proStatus: SessionPro.DecodedStatus = dependencies[singleton: .sessionProManager].proStatus( for: decodedMessage.decodedPro?.proProof, verifyPubkey: { switch threadVariant { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index a791d1c472..b741d3b339 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -79,7 +79,7 @@ extension MessageSender { .addedUsers( hasCurrentUser: false, names: sortedOtherMembers.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() }, historyShared: false @@ -684,7 +684,7 @@ extension MessageSender { .addedUsers( hasCurrentUser: members.contains { id, _ in id == userSessionId.hexString }, names: sortedMembers.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() }, historyShared: allowAccessToHistoricMessages @@ -1020,7 +1020,7 @@ extension MessageSender { .removedUsers( hasCurrentUser: memberIds.contains(userSessionId.hexString), names: sortedMemberIds.map { id in - removedMemberProfiles[id]?.displayName(for: .group) ?? + removedMemberProfiles[id]?.displayName() ?? id.truncated() } ) @@ -1163,7 +1163,7 @@ extension MessageSender { .map { id, _ in id } .contains(userSessionId.hexString), names: sortedMembersReceivingPromotions.map { id, profile in - profile?.displayName(for: .group) ?? + profile?.displayName() ?? id.truncated() } ) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 5ce9e2eac2..3dc2a45535 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -169,7 +169,7 @@ public extension NotificationsManagerType { threadVariant: SessionThread.Variant, isMessageRequest: Bool, notificationSettings: Preferences.NotificationSettings, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, groupNameRetriever: (String, SessionThread.Variant) -> String?, using dependencies: Dependencies ) throws -> String { @@ -188,12 +188,12 @@ public extension NotificationsManagerType { case (.nameNoPreview, .some(let sender), _, .contact), (.nameAndPreview, .some(let sender), _, .contact): return displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + .defaulting(to: sender.truncated()) case (.nameNoPreview, .some(let sender), _, .group), (.nameAndPreview, .some(let sender), _, .group), (.nameNoPreview, .some(let sender), _, .community), (.nameAndPreview, .some(let sender), _, .community): let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + .defaulting(to: sender.truncated()) let groupName: String = groupNameRetriever(threadId, threadVariant) .defaulting(to: "groupUnknown".localized()) @@ -215,7 +215,7 @@ public extension NotificationsManagerType { interactionVariant: Interaction.Variant?, attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) -> String { /// If it's a message request then use something generic @@ -245,7 +245,7 @@ public extension NotificationsManagerType { variant: variant, body: visibleMessage.text, authorDisplayName: displayNameRetriever(sender, true) - .defaulting(to: sender.truncated(threadVariant: threadVariant)), + .defaulting(to: sender.truncated()), attachmentDescriptionInfo: attachmentDescriptionInfo?.first, attachmentCount: (attachmentDescriptionInfo?.count ?? 0), isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil), @@ -269,24 +269,21 @@ public extension NotificationsManagerType { } case let callMessage as CallMessage where callMessage.state == .permissionDenied: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) return "callsYouMissedCallPermissions" .put(key: "name", value: senderName) .localizedDeformatted() case is CallMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) return "callsMissedCallFrom" .put(key: "name", value: senderName) .localizedDeformatted() case let inviteMessage as GroupUpdateInviteMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) let bodyText: String? = ClosedGroup.MessageInfo .invited(senderName, inviteMessage.groupName) .previewText @@ -302,8 +299,7 @@ public extension NotificationsManagerType { } case let promotionMessage as GroupUpdatePromoteMessage: - let senderName: String = displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + let senderName: String = (displayNameRetriever(sender, false) ?? sender.truncated()) let bodyText: String? = ClosedGroup.MessageInfo .invitedAdmin(senderName, promotionMessage.groupName) .previewText @@ -339,7 +335,7 @@ public extension NotificationsManagerType { applicationState: UIApplication.State, extensionBaseUnreadCount: Int?, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String?, + displayNameRetriever: DisplayNameRetriever, groupNameRetriever: (String, SessionThread.Variant) -> String?, shouldShowForMessageRequest: () -> Bool ) throws { diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 5819c505eb..7186e5d4cd 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -31,10 +31,15 @@ public actor SessionProManager: SessionProManagerType { private var isRefreshingState: Bool = false private var proStatusObservationTask: Task? private var rotatingKeyPair: KeyPair? + public var plans: [SessionPro.Plan] = [] public var proFeatures: SessionPro.Features = .none + nonisolated private let loadingStateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.loading) nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let autoRenewingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let accessExpiryTimestampMsStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let latestPaymentItemStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } nonisolated public var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { @@ -52,10 +57,14 @@ public actor SessionProManager: SessionProManagerType { (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) } + nonisolated public var loadingState: AsyncStream { loadingStateStream.stream } nonisolated public var backendUserProStatus: AsyncStream { backendUserProStatusStream.stream } nonisolated public var proProof: AsyncStream { proProofStream.stream } + nonisolated public var autoRenewing: AsyncStream { autoRenewingStream.stream } + nonisolated public var accessExpiryTimestampMs: AsyncStream { accessExpiryTimestampMsStream.stream } + nonisolated public var latestPaymentItem: AsyncStream { latestPaymentItemStream.stream } // MARK: - Initialization @@ -97,13 +106,13 @@ public actor SessionProManager: SessionProManagerType { for proof: Network.SessionPro.ProProof?, verifyPubkey: I?, atTimestampMs timestampMs: UInt64 - ) -> SessionPro.ProStatus { + ) -> SessionPro.DecodedStatus { guard let proof: Network.SessionPro.ProProof else { return .none } var cProProof: session_protocol_pro_proof = proof.libSessionValue let cVerifyPubkey: [UInt8] = (verifyPubkey.map { Array($0) } ?? []) - return SessionPro.ProStatus( + return SessionPro.DecodedStatus( session_protocol_pro_proof_status( &cProProof, cVerifyPubkey, @@ -182,11 +191,16 @@ public actor SessionProManager: SessionProManagerType { } /// Get the cached pro state from libSession - let (proConfig, profile): (SessionPro.ProConfig?, Profile) = dependencies.mutate(cache: .libSession) { - ($0.proConfig, $0.profile) + typealias ProState = ( + proConfig: SessionPro.ProConfig?, + profile: Profile, + accessExpiryTimestampMs: UInt64 + ) + let proState: ProState = dependencies.mutate(cache: .libSession) { + ($0.proConfig, $0.profile, $0.proAccessExpiryTimestampMs) } - let rotatingKeyPair: KeyPair? = try? proConfig.map { config in + let rotatingKeyPair: KeyPair? = try? proState.proConfig.map { config in guard config.rotatingPrivateKey.count >= 32 else { return nil } return try dependencies[singleton: .crypto].tryGenerate( @@ -197,7 +211,7 @@ public actor SessionProManager: SessionProManagerType { /// Update the `syncState` first (just in case an update triggered from the async state results in something accessing the /// sync state) let proStatus: Network.SessionPro.BackendUserProStatus = { - guard let proof: Network.SessionPro.ProProof = proConfig?.proProof else { + guard let proof: Network.SessionPro.ProProof = proState.proConfig?.proProof else { return .neverBeenPro } @@ -210,21 +224,20 @@ public actor SessionProManager: SessionProManagerType { syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), backendUserProStatus: .set(to: proStatus), - proProof: .set(to: proConfig?.proProof), - proFeatures: .set(to: profile.proFeatures) + proProof: .set(to: proState.proConfig?.proProof), + proFeatures: .set(to: proState.profile.proFeatures) ) /// Then update the async state and streams self.rotatingKeyPair = rotatingKeyPair - self.proFeatures = profile.proFeatures - await self.proProofStream.send(proConfig?.proProof) + self.proFeatures = proState.profile.proFeatures + await self.proProofStream.send(proState.proConfig?.proProof) await self.backendUserProStatusStream.send(proStatus) } @discardableResult @MainActor public func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { @@ -238,7 +251,6 @@ public actor SessionProManager: SessionProManagerType { break } - beforePresented?() let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( variant: variant, @@ -255,7 +267,7 @@ public actor SessionProManager: SessionProManagerType { public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { // TODO: [PRO] Need to actually implement this - dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .active) + dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .simulate(.active)) await backendUserProStatusStream.send(.active) completion?(true) } @@ -267,6 +279,17 @@ public actor SessionProManager: SessionProManagerType { guard !isRefreshingState else { return } isRefreshingState = true + defer { isRefreshingState = false } + + /// Only reset the `loadingState` if it's currently in an error state + if await loadingStateStream.getCurrent() == .error { + await loadingStateStream.send(.loading) + } + + /// Get the product list from the AppStore first (need this to populate the UI) + if plans.isEmpty { + plans = try await SessionPro.Plan.retrievePlans() + } // FIXME: Await network connectivity when the refactored networking is merged let request = try? Network.SessionPro.getProDetails( @@ -282,12 +305,15 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") Log.error(.sessionPro, "Failed to retrieve pro details due to error(s): \(errorString)") + await loadingStateStream.send(.error) throw NetworkError.explicit(errorString) } - // TODO: [PRO] Need to add an observable event for the pro status syncState.update(backendUserProStatus: .set(to: response.status)) await self.backendUserProStatusStream.send(response.status) + await self.autoRenewingStream.send(response.autoRenewing) + await self.accessExpiryTimestampMsStream.send(response.expiryTimestampMs) + await self.latestPaymentItemStream.send(response.items.first) switch response.status { case .active: @@ -301,7 +327,7 @@ public actor SessionProManager: SessionProManagerType { case .expired: try await clearProProof() } - isRefreshingState = false + await loadingStateStream.send(.success) } public func refreshProProofIfNeeded( @@ -494,13 +520,13 @@ public actor SessionProManager: SessionProManagerType { /// Ignore status updates if pro is enabled, and if the mock status was removed we need to fetch /// the "real" status guard dependencies[feature: .sessionProEnabled] else { continue } - guard let status: Network.SessionPro.BackendUserProStatus = status else { - try? await self?.refreshProState() - continue - } - self?.syncState.update(backendUserProStatus: .set(to: status)) - await self?.backendUserProStatusStream.send(status) + switch status { + case .useActual: try? await self?.refreshProState() + case .simulate(let status): + self?.syncState.update(backendUserProStatus: .set(to: status)) + await self?.backendUserProStatusStream.send(status) + } } } } @@ -559,20 +585,25 @@ private final class SessionProManagerSyncState { public protocol SessionProManagerType: SessionProUIManagerType { var proFeatures: SessionPro.Features { get } + var plans: [SessionPro.Plan] { get } nonisolated var characterLimit: Int { get } nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } nonisolated var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } + nonisolated var loadingState: AsyncStream { get } nonisolated var backendUserProStatus: AsyncStream { get } nonisolated var proProof: AsyncStream { get } + nonisolated var autoRenewing: AsyncStream { get } + nonisolated var accessExpiryTimestampMs: AsyncStream { get } + nonisolated var latestPaymentItem: AsyncStream { get } nonisolated func proStatus( for proof: Network.SessionPro.ProProof?, verifyPubkey: I?, atTimestampMs timestampMs: UInt64 - ) -> SessionPro.ProStatus + ) -> SessionPro.DecodedStatus nonisolated func proProofIsActive( for proof: Network.SessionPro.ProProof?, atTimestampMs timestampMs: UInt64 @@ -590,3 +621,60 @@ public extension SessionProManagerType { return features(for: message, features: .none) } } + +// MARK: - Observations + +// stringlint:ignore_contents +public extension ObservableKey { + static func currentUserProLoadingState(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProLoadingState", + generic: .currentUserProLoadingState + ) { [weak manager] in manager?.loadingState } + } + + static func currentUserProStatus(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProStatus", + generic: .currentUserProStatus + ) { [weak manager] in manager?.backendUserProStatus } + } + + static func currentUserProProof(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProProof", + generic: .currentUserProProof + ) { [weak manager] in manager?.proProof } + } + + static func currentUserProAutoRenewing(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProAutoRenewing", + generic: .currentUserProAutoRenewing + ) { [weak manager] in manager?.autoRenewing } + } + + static func currentUserProAccessExpiryTimestampMs(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProAccessExpiryTimestampMs", + generic: .currentUserProAccessExpiryTimestampMs + ) { [weak manager] in manager?.accessExpiryTimestampMs } + } + + static func currentUserProLatestPaymentItem(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProLatestPaymentItem", + generic: .currentUserProLatestPaymentItem + ) { [weak manager] in manager?.latestPaymentItem } + } +} + +// stringlint:ignore_contents +public extension GenericObservableKey { + static let currentUserProLoadingState: GenericObservableKey = "currentUserProLoadingState" + static let currentUserProStatus: GenericObservableKey = "currentUserProStatus" + static let currentUserProProof: GenericObservableKey = "currentUserProProof" + static let currentUserProAutoRenewing: GenericObservableKey = "currentUserProAutoRenewing" + static let currentUserProAccessExpiryTimestampMs: GenericObservableKey = "currentUserProAccessExpiryTimestampMs" + static let currentUserProLatestPaymentItem: GenericObservableKey = "currentUserProLatestPaymentItem" +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift index 68dc5044f4..6ba26b9d23 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -6,7 +6,7 @@ import SessionNetworkingKit public extension SessionPro { struct DecodedProForMessage: Sendable, Codable, Equatable { - let status: SessionPro.ProStatus + let status: SessionPro.DecodedStatus let proProof: Network.SessionPro.ProProof let features: Features @@ -18,14 +18,14 @@ public extension SessionPro { // MARK: - Initialization - init(status: SessionPro.ProStatus, proProof: Network.SessionPro.ProProof, features: Features) { + init(status: SessionPro.DecodedStatus, proProof: Network.SessionPro.ProProof, features: Features) { self.status = status self.proProof = proProof self.features = features } init(_ libSessionValue: session_protocol_decoded_pro) { - status = SessionPro.ProStatus(libSessionValue.status) + status = SessionPro.DecodedStatus(libSessionValue.status) proProof = Network.SessionPro.ProProof(libSessionValue.proof) features = Features(libSessionValue.features) } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift similarity index 96% rename from SessionMessagingKit/SessionPro/Types/SessionProStatus.swift rename to SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift index a43ddccd6e..de7ac62924 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift @@ -7,7 +7,7 @@ import SessionUtil import SessionUtilitiesKit public extension SessionPro { - enum ProStatus: Sendable, Codable, CaseIterable { + enum DecodedStatus: Sendable, Codable, CaseIterable { case none case invalidProBackendSig case invalidUserSig diff --git a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift new file mode 100644 index 0000000000..241c9366c9 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift @@ -0,0 +1,138 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import StoreKit +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct Plan: Equatable, Sendable { + private static let productIds: [String] = [ + "com.getsession.org.pro_sub_1_month", + "com.getsession.org.pro_sub_3_months", + "com.getsession.org.pro_sub_12_months" + ] + + public let id: String + public let variant: Network.SessionPro.Plan + public let durationMonths: Int + public let price: Decimal + public let pricePerMonth: Decimal + public let discountPercent: Int? + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.variant == rhs.variant + } + + // MARK: - Functions + + public static func retrievePlans() async throws -> [Plan] { +#if targetEnvironment(simulator) + return [ + Plan( + id: "SimId3", + variant: .twelveMonths, + durationMonths: 12, + price: 111, + pricePerMonth: 9.25, + discountPercent: 75 + ), + Plan( + id: "SimId2", + variant: .threeMonths, + durationMonths: 3, + price: 222, + pricePerMonth: 74, + discountPercent: 50 + ), + Plan( + id: "SimId1", + variant: .oneMonth, + durationMonths: 1, + price: 444, + pricePerMonth: 444, + discountPercent: nil + ) + ] +#endif + let products: [Product] = try await Product + .products(for: productIds) + .sorted() + .reversed() + + guard let shortestProductPrice: Decimal = products.last?.price else { + return [] + } + + return products.map { product in + let durationMonths: Int = product.durationMonths + let priceDiff: Decimal = (shortestProductPrice - product.price) + let discountDecimal: Decimal = ((priceDiff / shortestProductPrice) * 100) + let discount: Int = Int(truncating: discountDecimal as NSNumber) + let variant: Network.SessionPro.Plan = { + switch durationMonths { + case 1: return .oneMonth + case 3: return .threeMonths + case 12: return .twelveMonths + default: + Log.error("Received a subscription product with an invalid duration: \(durationMonths), product id: \(product.id)") + return .none + } + }() + + return Plan( + id: product.id, + variant: variant, + durationMonths: durationMonths, + price: product.price, + pricePerMonth: (product.price / Decimal(durationMonths)), + discountPercent: (variant != .oneMonth ? discount : nil) + ) + } + } + } +} + +// MARK: - Convenience + +extension Product: @retroactive Comparable { + var durationMonths: Int { + guard let subscription: SubscriptionInfo = subscription else { return -1 } + + switch subscription.subscriptionPeriod.unit { + case .day: return (subscription.subscriptionPeriod.value / 30) + case .week: return (subscription.subscriptionPeriod.value / 4) + case .month: return subscription.subscriptionPeriod.value + case .year: return (subscription.subscriptionPeriod.value * 12) + @unknown default: return subscription.subscriptionPeriod.value + } + } + + public static func < (lhs: Product, rhs: Product) -> Bool { + guard + let lhsSubscription: SubscriptionInfo = lhs.subscription, + let rhsSubscription: SubscriptionInfo = rhs.subscription, ( + lhsSubscription.subscriptionPeriod.unit != rhsSubscription.subscriptionPeriod.unit || + lhsSubscription.subscriptionPeriod.value != rhsSubscription.subscriptionPeriod.value + ) + else { return lhs.id < rhs.id } + + func approximateDurationDays(_ subscription: SubscriptionInfo) -> Int { + switch subscription.subscriptionPeriod.unit { + case .day: return subscription.subscriptionPeriod.value + case .week: return subscription.subscriptionPeriod.value * 7 + case .month: return subscription.subscriptionPeriod.value * 30 + case .year: return subscription.subscriptionPeriod.value * 365 + @unknown default: return subscription.subscriptionPeriod.value + } + } + + let lhsApproxDays: Int = approximateDurationDays(lhsSubscription) + let rhsApproxDays: Int = approximateDurationDays(rhsSubscription) + + guard lhsApproxDays != rhsApproxDays else { return lhs.id < rhs.id } + + return (lhsApproxDays < rhsApproxDays) + } +} diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index bba5e2a125..df0a2a89b2 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -51,6 +51,9 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif public let serverHash: String? public let openGroupServerMessageId: Int64? public let authorId: String + + /// The value will be populated if the sender has a blinded id and we have resolved it to an unblinded id + public let authorUnblindedId: String? public let body: String? public let rawBody: String? public let timestampMs: Int64 @@ -60,17 +63,11 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif public let attachments: [Attachment] public let reactionInfo: [ReactionInfo] public let profile: Profile - public let quotedInfo: QuotedInfo? + public let quoteViewModel: QuoteViewModel? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? public let proFeatures: SessionPro.Features - /// This value includes the author name information - public let authorName: String - - /// This value includes the author name information with the `id` suppressed (if it was present) - public let authorNameSuppressedId: String - public let state: Interaction.State public let hasBeenReadByRecipient: Bool public let mostRecentFailureText: String? @@ -130,7 +127,7 @@ public extension MessageViewModel { timestampMs: Int64, variant: Interaction.Variant = .standardOutgoing, body: String? = nil, - quotedInfo: MessageViewModel.QuotedInfo? = nil, + quoteViewModel: QuoteViewModel? = nil, isLast: Bool = true ) { self.id = { @@ -144,7 +141,7 @@ public extension MessageViewModel { self.timestampMs = timestampMs self.variant = variant self.body = body - self.quotedInfo = quotedInfo + self.quoteViewModel = quoteViewModel /// These values shouldn't be used for the custom types self.optimisticMessageId = nil @@ -154,6 +151,7 @@ public extension MessageViewModel { self.serverHash = "" self.openGroupServerMessageId = nil self.authorId = "" + self.authorUnblindedId = nil self.rawBody = nil self.receivedAtTimestampMs = 0 self.expiresStartedAtMs = nil @@ -165,8 +163,6 @@ public extension MessageViewModel { self.linkPreviewAttachment = nil self.proFeatures = .none - self.authorName = "" - self.authorNameSuppressedId = "" self.state = .localOnly self.hasBeenReadByRecipient = false self.mostRecentFailureText = nil @@ -196,11 +192,12 @@ public extension MessageViewModel { threadDisappearingConfiguration: DisappearingMessagesConfiguration?, interaction: Interaction, reactionInfo: [ReactionInfo]?, - quotedInteraction: Interaction?, + maybeUnresolvedQuotedInfo: MaybeUnresolvedQuotedInfo?, profileCache: [String: Profile], attachmentCache: [String: Attachment], linkPreviewCache: [String: [LinkPreview]], attachmentMap: [Int64: Set], + unblindedIdMap: [String: String], isSenderModeratorOrAdmin: Bool, userSessionId: SessionId, currentUserSessionIds: Set, @@ -219,33 +216,24 @@ public extension MessageViewModel { } let targetProfile: Profile = { - /// If the reactor is the current user then use the proper profile from the cache (instead of a random blinded one) + /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) guard !currentUserSessionIds.contains(interaction.authorId) else { return (profileCache[userSessionId.hexString] ?? Profile.defaultFor(userSessionId.hexString)) } - return (profileCache[interaction.authorId] ?? Profile.defaultFor(interaction.authorId)) - }() - let authorDisplayName: String = { - guard !currentUserSessionIds.contains(interaction.authorId) else { return "you".localized() } - - return Profile.displayName( - for: threadVariant, - id: interaction.authorId, - name: targetProfile.name, - nickname: targetProfile.nickname, - suppressId: false // Show the id next to the author name if desired - ) + switch (profileCache[unblindedIdMap[interaction.authorId]], profileCache[interaction.authorId]) { + case (.some(let profile), _): return profile + case (_, .some(let profile)): return profile + case (.none, .none): return Profile.defaultFor(interaction.authorId) + } }() let threadContactDisplayName: String? = { switch threadVariant { case .contact: return Profile.displayName( - for: threadVariant, id: threadId, name: profileCache[threadId]?.name, - nickname: profileCache[threadId]?.nickname, - suppressId: false // Show the id next to the author name if desired + nickname: profileCache[threadId]?.nickname ) default: return nil @@ -262,7 +250,12 @@ public extension MessageViewModel { threadId: threadId, threadVariant: threadVariant, threadContactDisplayName: threadContactDisplayName, - authorDisplayName: authorDisplayName, + authorDisplayName: (currentUserSessionIds.contains(targetProfile.id) ? + "you".localized() : + targetProfile.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + ), attachments: attachments, linkPreview: linkPreviewInfo?.preview, using: dependencies @@ -289,6 +282,7 @@ public extension MessageViewModel { self.serverHash = interaction.serverHash self.openGroupServerMessageId = interaction.openGroupServerMessageId self.authorId = interaction.authorId + self.authorUnblindedId = unblindedIdMap[authorId] self.body = body self.rawBody = interaction.body self.timestampMs = interaction.timestampMs @@ -298,16 +292,52 @@ public extension MessageViewModel { self.attachments = attachments self.reactionInfo = (reactionInfo ?? []) self.profile = targetProfile - self.quotedInfo = quotedInteraction.map { quotedInteraction -> QuotedInfo? in - guard let quoteInteractionId: Int64 = quotedInteraction.id else { return nil } + self.quoteViewModel = maybeUnresolvedQuotedInfo.map { info -> QuoteViewModel? in + /// Should be `interaction` not `quotedInteraction` + let targetDirection: QuoteViewModel.Direction = (interaction.variant.isOutgoing ? + .outgoing : + .incoming + ) + + /// If the message contains a `Quote` but we couldn't resolve the original message then we still want to return a + /// `QuoteViewModel` so that it's rendered correctly (it'll just render that it couldn't resolve) + guard + let quotedInteractionId: Int64 = info.foundQuotedInteractionId, + let quotedInteraction: Interaction = info.resolvedQuotedInteraction + else { + return QuoteViewModel( + mode: .regular, + direction: targetDirection, + quotedInfo: nil, + showProBadge: false, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { _, _ in nil } + ) + } - let quotedAttachments: [Attachment]? = (attachmentMap[quotedInteraction.id]? + let quotedAuthorProfile: Profile = { + /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { + return (profileCache[userSessionId.hexString] ?? Profile.defaultFor(userSessionId.hexString)) + } + + switch (profileCache[unblindedIdMap[quotedInteraction.authorId]], profileCache[quotedInteraction.authorId]) { + case (.some(let profile), _): return profile + case (_, .some(let profile)): return profile + case (.none, .none): return Profile.defaultFor(quotedInteraction.authorId) + } + }() + let quotedAuthorDisplayName: String = quotedAuthorProfile.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + let quotedAttachments: [Attachment]? = (attachmentMap[quotedInteractionId]? .sorted { $0.albumIndex < $1.albumIndex } .compactMap { attachmentCache[$0.attachmentId] } ?? []) let quotedLinkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = quotedInteraction.linkPreview( linkPreviewCache: linkPreviewCache, attachmentCache: attachmentCache ) + let targetQuotedAttachment: Attachment? = (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment) let quotedInteractionProFeatures: SessionPro.Features = { guard dependencies[feature: .sessionProEnabled] else { return .none } @@ -316,52 +346,65 @@ public extension MessageViewModel { .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) }() - let quotedAuthorDisplayName: String = { - guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { return "you".localized() } - - return Profile.displayName( - for: threadVariant, - id: interaction.authorId, - name: profileCache[quotedInteraction.authorId]?.name, - nickname: profileCache[quotedInteraction.authorId]?.nickname, - suppressId: false // Show the id next to the author name if desired - ) - }() - return MessageViewModel.QuotedInfo( - interactionId: quoteInteractionId, - authorName: quotedAuthorDisplayName, - timestampMs: quotedInteraction.timestampMs, - body: quotedInteraction.body( - threadId: threadId, - threadVariant: threadVariant, - threadContactDisplayName: threadContactDisplayName, - authorDisplayName: quotedAuthorDisplayName, - attachments: quotedAttachments, - linkPreview: quotedLinkPreviewInfo?.preview, - using: dependencies + return QuoteViewModel( + mode: .regular, + direction: targetDirection, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: quotedInteractionId, + authorId: quotedInteraction.authorId, + authorName: quotedAuthorDisplayName, + timestampMs: quotedInteraction.timestampMs, + body: quotedInteraction.body( + threadId: threadId, + threadVariant: threadVariant, + threadContactDisplayName: threadContactDisplayName, + authorDisplayName: quotedAuthorDisplayName, + attachments: quotedAttachments, + linkPreview: quotedLinkPreviewInfo?.preview, + using: dependencies + ), + attachmentInfo: targetQuotedAttachment.map { quotedAttachment in + let utType: UTType = (UTType(sessionMimeType: quotedAttachment.contentType) ?? .invalid) + + return QuoteViewModel.AttachmentInfo( + id: quotedAttachment.id, + utType: utType, + isVoiceMessage: (quotedAttachment.variant == .voiceMessage), + downloadUrl: quotedAttachment.downloadUrl, + sourceFilename: quotedAttachment.sourceFilename, + thumbnailSource: quotedAttachment.downloadUrl.map { downloadUrl -> ImageDataManager.DataSource? in + guard + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: downloadUrl) + else { return nil } + + return .thumbnailFrom( + utType: utType, + path: path, + sourceFilename: quotedAttachment.sourceFilename, + size: .small, + using: dependencies + ) + } + ) + } ), - attachment: (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment), - proFeatures: quotedInteractionProFeatures + showProBadge: quotedInteractionProFeatures.contains(.proBadge), + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { sessionId, _ in + guard !currentUserSessionIds.contains(targetProfile.id) else { return "you".localized() } + + return profileCache[sessionId]?.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + } ) } self.linkPreview = linkPreviewInfo?.preview self.linkPreviewAttachment = linkPreviewInfo?.attachment self.proFeatures = proFeatures - self.authorName = authorDisplayName - self.authorNameSuppressedId = { - guard !currentUserSessionIds.contains(interaction.authorId) else { return "you".localized() } - - return Profile.displayName( - for: threadVariant, - id: interaction.authorId, - name: targetProfile.name, - nickname: targetProfile.nickname, - suppressId: true // Exclude the id next to the author name - ) - }() - self.state = interaction.state self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) self.mostRecentFailureText = interaction.mostRecentFailureText @@ -508,6 +551,7 @@ public extension MessageViewModel { serverHash: serverHash, openGroupServerMessageId: openGroupServerMessageId, authorId: authorId, + authorUnblindedId: authorUnblindedId, body: body, rawBody: rawBody, timestampMs: timestampMs, @@ -517,12 +561,10 @@ public extension MessageViewModel { attachments: attachments, reactionInfo: reactionInfo, profile: profile, - quotedInfo: quotedInfo, + quoteViewModel: quoteViewModel, linkPreview: linkPreview, linkPreviewAttachment: linkPreviewAttachment, proFeatures: proFeatures, - authorName: authorName, - authorNameSuppressedId: authorNameSuppressedId, state: state.or(self.state), hasBeenReadByRecipient: hasBeenReadByRecipient, mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), @@ -542,6 +584,16 @@ public extension MessageViewModel { currentUserSessionIds: currentUserSessionIds ) } + + func authorName( + ignoreNickname: Bool = false + ) -> String { + return profile.displayName( + ignoreNickname: ignoreNickname, + showYouForCurrentUser: true, + currentUserSessionIds: currentUserSessionIds + ) + } } // MARK: - DisappeaingMessagesUpdateControlMessage @@ -603,57 +655,21 @@ public extension MessageViewModel { } } -// MARK: - QuotedInfo -// TODO: [PRO] Replace this with `QuoteViewModel`??? +// MARK: - MaybeUnresolvedQuotedInfo + public extension MessageViewModel { - struct QuotedInfo: Sendable, Equatable, Hashable { - public let interactionId: Int64 - public let authorName: String - public let timestampMs: Int64 - public let body: String? - public let attachment: Attachment? - public let proFeatures: SessionPro.Features - - // MARK: - Initialization + /// If the message contains a `Quote` but we couldn't resolve the original message then we should display the "original message + /// not found" UI (ie. show that there _was_ a quote there, even if we can't resolve it) - this type makes that possible + struct MaybeUnresolvedQuotedInfo: Sendable, Equatable, Hashable { + public let foundQuotedInteractionId: Int64? + public let resolvedQuotedInteraction: Interaction? public init( - interactionId: Int64, - authorName: String, - timestampMs: Int64, - body: String?, - attachment: Attachment?, - proFeatures: SessionPro.Features + foundQuotedInteractionId: Int64?, + resolvedQuotedInteraction: Interaction? = nil ) { - self.interactionId = interactionId - self.authorName = authorName - self.timestampMs = timestampMs - self.body = body - self.attachment = attachment - self.proFeatures = proFeatures - } - - public init(previewBody: String) { - self.body = previewBody - - /// This is a preview version so none of these values matter - self.interactionId = -1 - self.authorName = "" - self.timestampMs = 0 - self.attachment = nil - self.proFeatures = .none - } - - public init?(replyModel: QuotedReplyModel?, authorName: String?) { - guard let model: QuotedReplyModel = replyModel else { return nil } - - self.authorName = (authorName ?? model.authorId.truncated()) - self.timestampMs = model.timestampMs - self.body = model.body - self.attachment = model.attachment - self.proFeatures = model.proFeatures - - /// This is an optimistic version so none of these values exist yet - self.interactionId = -1 + self.foundQuotedInteractionId = foundQuotedInteractionId + self.resolvedQuotedInteraction = resolvedQuotedInteraction } } } @@ -712,18 +728,18 @@ public extension MessageViewModel { static func quotedInteractionIds( for originalInteractionIds: [Int64], currentUserSessionIds: Set - ) -> SQLRequest> { + ) -> SQLRequest> { let interaction: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") return """ SELECT - \(interaction[.id]) AS \(FetchablePair.Columns.first), - \(quoteInteraction[.id]) AS \(FetchablePair.Columns.second) + \(interaction[.id]) AS \(FetchablePair.Columns.first), + \(quoteInteraction[.id]) AS \(FetchablePair.Columns.second) FROM \(Interaction.self) JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - JOIN \(quoteInteraction) ON ( + LEFT JOIN \(quoteInteraction) ON ( \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id @@ -738,6 +754,72 @@ public extension MessageViewModel { } } +extension MessageViewModel { + public func createUserProfileModalInfo( + onStartThread: (@MainActor () -> Void)?, + onProBadgeTapped: (@MainActor () -> Void)?, + using dependencies: Dependencies + ) -> UserProfileModal.Info? { + let (info, _) = ProfilePictureView.Info.generateInfoFrom( + size: .hero, + publicKey: authorId, + threadVariant: .contact, /// Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: profile, + using: dependencies + ) + + guard let profileInfo: ProfilePictureView.Info = info else { return nil } + + let qrCodeImage: UIImage? = { + let targetId: String = (authorUnblindedId ?? authorId) + + switch try? SessionId.Prefix(from: targetId) { + case .none, .blinded15, .blinded25, .versionBlinded07, .group, .unblinded: return nil + case .standard: + return QRCode.generate( + for: targetId, + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + } + }() + let sessionId: String? = { + if let unblindedId: String = authorUnblindedId { + return unblindedId + } + + switch try? SessionId.Prefix(from: authorId) { + case .none, .blinded15, .blinded25, .versionBlinded07, .group, .unblinded: return nil + case .standard: return authorId + } + }() + let blindedId: String? = { + switch try? SessionId.Prefix(from: authorId) { + case .none, .standard, .versionBlinded07, .group, .unblinded: return nil + case .blinded15, .blinded25: return authorId + } + }() + + return UserProfileModal.Info( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: authorName(), + contactDisplayName: authorName(ignoreNickname: true), + shouldShowProBadge: profile.proFeatures.contains(.proBadge), + areMessageRequestsEnabled: { + guard threadVariant == .community else { return true } + + return (profile.blocksCommunityMessageRequests != true) + }(), + onStartThread: onStartThread, + onProBadgeTapped: onProBadgeTapped + ) + } +} + // MARK: - Construction private extension MessageViewModel { diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 5808c3722c..aed514fe24 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -188,7 +188,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D closedGroupName: closedGroupName, openGroupName: openGroupName, isNoteToSelf: threadIsNoteToSelf, - ignoringNickname: false, + ignoreNickname: false, profile: profile ) } @@ -200,7 +200,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D closedGroupName: closedGroupName, openGroupName: openGroupName, isNoteToSelf: threadIsNoteToSelf, - ignoringNickname: true, + ignoreNickname: true, profile: profile ) } @@ -244,38 +244,6 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D return Date(timeIntervalSince1970: TimeInterval(Double(interactionTimestampMs) / 1000)) } - public var messageInputState: InputView.InputState { - guard !threadIsNoteToSelf else { return InputView.InputState(inputs: .all) } - guard threadIsBlocked != true else { - return InputView.InputState( - inputs: .disabled, - message: "blockBlockedDescription".localized(), - messageAccessibility: Accessibility( - identifier: "Blocked banner" - ) - ) - } - - if threadVariant == .community && threadCanWrite == false { - return InputView.InputState( - inputs: .disabled, - message: "permissionsWriteCommunity".localized() - ) - } - - /// Attachments shouldn't be allowed for message requests or if uploads are disabled - let finalInputs: InputView.Input - - switch (threadRequiresApproval, threadIsMessageRequest, threadCanUpload) { - case (false, false, true): finalInputs = .all - default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] - } - - return InputView.InputState( - inputs: finalInputs - ) - } - public var userCount: Int? { switch threadVariant { case .contact: return nil @@ -291,12 +259,10 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D /// parameter public func threadContactName() -> String { return Profile.displayName( - for: .contact, id: threadId, name: threadContactNameInternal, nickname: nil, // Folded into 'threadContactNameInternal' within the Query - suppressId: true, // Don't include the account id in the name in the conversation list - customFallback: "Anonymous" + customFallback: "anonymous".localized() ) } @@ -307,13 +273,11 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D /// parameter public func authorName(for threadVariant: SessionThread.Variant) -> String { return Profile.displayName( - for: threadVariant, id: (authorId ?? threadId), name: authorNameInternal, nickname: nil, // Folded into 'authorName' within the Query - suppressId: true, // Don't include the account id in the name in the conversation list customFallback: (threadVariant == .contact ? - "Anonymous" : + "anonymous".localized() : nil ) ) @@ -425,6 +389,28 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D } } + // MARK: - Draft + + public func updateDraft(_ draft: String, using dependencies: Dependencies) async throws { + let threadId: String = self.threadId + let existingDraft: String = (try await dependencies[singleton: .storage].readAsync { db in + try SessionThread + .select(.messageDraft) + .filter(id: threadId) + .asRequest(of: String.self) + .fetchOne(db) + } ?? "") + + guard draft != existingDraft else { return } + + try await dependencies[singleton: .storage].writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + db.addConversationEvent(id: threadId, type: .updated(.draft(draft))) + } + } + // MARK: - Functions /// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/SessionMessagingKit/Utilities/ImageLoading+Convenience.swift similarity index 91% rename from Session/Utilities/ImageLoading+Convenience.swift rename to SessionMessagingKit/Utilities/ImageLoading+Convenience.swift index 24d72be03b..63b8a50349 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/SessionMessagingKit/Utilities/ImageLoading+Convenience.swift @@ -4,7 +4,6 @@ import UIKit import SwiftUI import UniformTypeIdentifiers import SessionUIKit -import SessionMessagingKit import SessionUtilitiesKit // MARK: - ImageDataManager.DataSource Convenience @@ -51,25 +50,6 @@ public extension ImageDataManager.DataSource { ) } - static func thumbnailFrom( - quoteViewModel: QuoteViewModel, - using dependencies: Dependencies - ) -> ImageDataManager.DataSource? { - guard - let info: QuoteViewModel.AttachmentInfo = quoteViewModel.quotedAttachmentInfo, - let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: info.downloadUrl) - else { return nil } - - return .thumbnailFrom( - utType: info.utType, - path: path, - sourceFilename: info.sourceFilename, - size: .small, - using: dependencies - ) - } - static func thumbnailFrom( utType: UTType, path: String, diff --git a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift index df241007d2..a6e5ab84fc 100644 --- a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift @@ -11,11 +11,9 @@ public extension MentionSelectionView.ViewModel { profiles: [Profile], threadVariant: SessionThread.Variant, currentUserSessionIds: Set, - adminModMembers: [GroupMember], + adminModIds: [String], using dependencies: Dependencies ) -> [MentionSelectionView.ViewModel] { - let adminModIds: Set = Set(adminModMembers.map { $0.profileId }) - return profiles.compactMap { profile -> MentionSelectionView.ViewModel? in guard let info: ProfilePictureView.Info = ProfilePictureView.Info.generateInfoFrom( size: MentionSelectionView.profilePictureViewSize, @@ -29,9 +27,10 @@ public extension MentionSelectionView.ViewModel { return MentionSelectionView.ViewModel( profileId: profile.id, - displayName: profile.displayNameForMention( - for: threadVariant, - currentUserSessionIds: currentUserSessionIds + displayName: profile.displayName( + showYouForCurrentUser: true, + currentUserSessionIds: currentUserSessionIds, + includeSessionIdSuffix: (threadVariant == .community) ), profilePictureInfo: info ) @@ -46,50 +45,55 @@ public extension MentionSelectionView.ViewModel { communityInfo: (server: String, roomToken: String)?, using dependencies: Dependencies ) async throws -> [MentionSelectionView.ViewModel] { - let (profiles, adminModMembers): ([Profile], [GroupMember]) = try await dependencies[singleton: .storage].readAsync { db in + let profiles: [Profile] = try await dependencies[singleton: .storage].readAsync { db in let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadVariant != .community ? - nil : - try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == communityInfo?.server) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - ) - .defaulting(to: []) - let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? - [.blinded15, .blinded25] : - [.standard] - ) - let profiles: [Profile] = try mentionsQuery( + let targetPrefixes: [SessionId.Prefix] = { + switch threadVariant { + case .contact, .legacyGroup, .group: return [.standard] + case .community: + let capabilities: Set = ((try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == communityInfo?.server) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) ?? []) + + guard capabilities.contains(.blind) else { + return [.standard] + } + + return [.blinded15, .blinded25] + } + }() + + return try mentionsQuery( threadId: threadId, threadVariant: threadVariant, targetPrefixes: targetPrefixes, currentUserSessionIds: currentUserSessionIds, pattern: pattern ).fetchAll(db) - - /// If it's not a community then no need to determine admin/moderator status - guard threadVariant == .community, let communityId: String = communityInfo.map({ OpenGroup.idFor(roomToken: $0.roomToken, server: $0.server) }) else { - return (profiles, []) - } - - let adminModMembers: [GroupMember] = try dependencies[singleton: .openGroupManager].membersWhere( - db, - currentUserSessionIds: currentUserSessionIds, - .groupIds([communityId]), - .publicKeys(profiles.map { $0.id }), - .roles([.moderator, .admin]) - ) - - return (profiles, adminModMembers) } + let adminModIds: [String] = await { + switch (threadVariant, communityInfo) { + case (.contact, _), (.group, _), (.legacyGroup, _), (.community, .none): return [] + case (.community, .some(let communityInfo)): + guard let server: CommunityManager.Server = await dependencies[singleton: .communityManager].server(communityInfo.server) else { + return [] + } + + return ( + (server.rooms[communityInfo.roomToken]?.admins ?? []) + + (server.rooms[communityInfo.roomToken]?.moderators ?? []) + ) + } + }() + return mentions( profiles: profiles, threadVariant: threadVariant, currentUserSessionIds: currentUserSessionIds, - adminModMembers: adminModMembers, + adminModIds: adminModIds, using: dependencies ) } @@ -156,7 +160,7 @@ public extension MentionSelectionView.ViewModel { switch threadVariant { case .contact: return SQLRequest(""" - SELECT \(Profile.self).*, + SELECT \(Profile.self).* \(targetJoin) \(targetWhere) AND ( \(SQL("\(profile[.id]) = \(threadId)")) OR @@ -167,7 +171,7 @@ public extension MentionSelectionView.ViewModel { case .legacyGroup, .group: return SQLRequest(""" - SELECT \(Profile.self).*, + SELECT \(Profile.self).* \(targetJoin) JOIN \(GroupMember.self) ON ( \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index e0571345a8..65c3be3575 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -42,12 +42,18 @@ public extension ObservableKey { ObservableKey("contact-\(id)", .contact) } - static let anyContactBlockedStatusChanged: ObservableKey = "anyContactBlockedStatusChanged" + static let anyContactBlockedStatusChanged: ObservableKey = { + ObservableKey("anyContactBlockedStatusChanged", .anyContactBlockedStatusChanged) + }() + static let anyContactUnblinded: ObservableKey = ObservableKey("anyContactUnblinded", .anyContactUnblinded) // MARK: - Conversations - static let conversationCreated: ObservableKey = "conversationCreated" - static let anyConversationPinnedPriorityChanged: ObservableKey = "anyConversationPinnedPriorityChanged" + static let conversationCreated: ObservableKey = ObservableKey("conversationCreated", .conversationCreated) + static let anyConversationPinnedPriorityChanged: ObservableKey = { + ObservableKey("anyConversationPinnedPriorityChanged", .anyConversationPinnedPriorityChanged) + }() + static func conversationUpdated(_ id: String) -> ObservableKey { ObservableKey("conversationUpdated-\(id)", .conversationUpdated) } @@ -111,7 +117,11 @@ public extension GenericObservableKey { static let typingIndicator: GenericObservableKey = "typingIndicator" static let profile: GenericObservableKey = "profile" static let contact: GenericObservableKey = "contact" + static let anyContactBlockedStatusChanged: GenericObservableKey = "anyContactBlockedStatusChanged" + static let anyContactUnblinded: GenericObservableKey = "anyContactUnblinded" + static let conversationCreated: GenericObservableKey = "conversationCreated" + static let anyConversationPinnedPriorityChanged: GenericObservableKey = "anyConversationPinnedPriorityChanged" static let conversationUpdated: GenericObservableKey = "conversationUpdated" static let conversationDeleted: GenericObservableKey = "conversationDeleted" static let messageCreated: GenericObservableKey = "messageCreated" @@ -238,6 +248,7 @@ public extension ObservingDatabase { /// window includes the record, so we need to emit generic "any" events for these cases switch change { case .isBlocked: addEvent(ObservedEvent(key: .anyContactBlockedStatusChanged, value: event)) + case .unblinded: addEvent(ObservedEvent(key: .anyContactUnblinded, value: event)) default: break } @@ -262,6 +273,7 @@ public struct ConversationEvent: Hashable { case markedAsUnread(Bool) case unreadCount case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) + case draft(String?) } } diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 792b6ddc65..3fd1d96279 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -80,9 +80,6 @@ public extension Setting.BoolKey { /// There is no native api to get local network permission, so we need to modify the state and store in database to update UI accordingly. /// Remove this in the future if Apple provides native api static let lastSeenHasLocalNetworkPermission: Setting.BoolKey = "lastSeenHasLocalNetworkPermission" - - /// Controls whether sending pro badges bitmask - static let isProBadgeEnabled: Setting.BoolKey = "isProBagesEnabled" } // stringlint:ignore_contents diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index f1d2bb3b03..572dafeb68 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -288,25 +288,6 @@ public extension Profile { default: break } - /// Update the pro state based on whether the updated display picture is animated or not - if isCurrentUser, case .currentUserUpdateTo(_, _, let type) = displayPictureUpdate { - switch type { - case .staticImage: - updatedProState = ProfileProState( - features: updatedProState.features.removing(.animatedAvatar), - expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, - genIndexHashHex: updatedProState.genIndexHashHex - ) - case .animatedImage: - updatedProState = ProfileProState( - features: updatedProState.features.inserting(.animatedAvatar), - expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, - genIndexHashHex: updatedProState.genIndexHashHex - ) - case .reupload, .config: break /// Don't modify the current state - } - } - /// Session Pro Information (if it's not the current user) switch (proUpdate, isCurrentUser) { case (.none, _): break @@ -333,6 +314,25 @@ public extension Profile { default: break } + /// Update the pro state based on whether the updated display picture is animated or not + if isCurrentUser, case .currentUserUpdateTo(_, _, let type) = displayPictureUpdate { + switch type { + case .staticImage: + updatedProState = ProfileProState( + features: updatedProState.features.removing(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + case .animatedImage: + updatedProState = ProfileProState( + features: updatedProState.features.inserting(.animatedAvatar), + expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, + genIndexHashHex: updatedProState.genIndexHashHex + ) + case .reupload, .config: break /// Don't modify the current state + } + } + /// If the pro state no longer matches then we need to emit an event if updatedProState != proState { if updatedProState.features != proState.features { diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index e29f081c04..f900658beb 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -106,8 +106,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: (profile?.id ?? publicKey), - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), + text: (profile?.displayName() ?? publicKey), size: (additionalProfile != nil ? size.multiImageSize : size.viewSize @@ -134,7 +133,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: other.id, - text: other.displayName(for: threadVariant), + text: other.displayName(), size: size.multiImageSize ) } @@ -174,8 +173,7 @@ public extension ProfilePictureView.Info { else { return .placeholderIcon( seed: publicKey, - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), + text: (profile?.displayName() ?? publicKey), size: size.viewSize ) } diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift index f2bb07257c..9c5c1eeb51 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift @@ -5,13 +5,13 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct PaymentItem: Equatable { - let status: PaymentStatus - let plan: Plan - let paymentProvider: PaymentProvider - let paymentProviderMetadata: PaymentProviderMetadata? + struct PaymentItem: Sendable, Equatable, Hashable { + public let status: PaymentStatus + public let plan: Plan + public let paymentProvider: PaymentProvider? + public let paymentProviderMetadata: PaymentProviderMetadata? - let autoRenewing: Bool + public let autoRenewing: Bool let unredeemedTimestampMs: UInt64 let redeemedTimestampMs: UInt64 let expiryTimestampMs: UInt64 diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift index a8e1f6e055..5052a750ca 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProvider.swift @@ -4,25 +4,23 @@ import Foundation import SessionUtil public extension Network.SessionPro { - enum PaymentProvider: CaseIterable { - case none + enum PaymentProvider: Sendable, Equatable, Hashable, CaseIterable { case playStore case appStore var libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER { switch self { - case .none: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL case .playStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE case .appStore: return SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE } } - init(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER) { + init?(_ libSessionValue: SESSION_PRO_BACKEND_PAYMENT_PROVIDER) { switch libSessionValue { - case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL: self = .none + case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL: return nil case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE: self = .playStore case SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE: self = .appStore - default: self = .none + default: return nil } } } diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift index 8684e4561c..2673d86e6a 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct PaymentProviderMetadata: Equatable { + struct PaymentProviderMetadata: Sendable, Equatable, Hashable { let device: String let store: String let platform: String diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift index 909ac56112..8ab5639fd5 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentStatus.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtil public extension Network.SessionPro { - enum PaymentStatus: CaseIterable { + enum PaymentStatus: Sendable, Equatable, Hashable, CaseIterable { case none case unredeemed case redeemed diff --git a/SessionNetworkingKit/SessionPro/Types/Plan.swift b/SessionNetworkingKit/SessionPro/Types/Plan.swift index 758db75299..10ceda9aae 100644 --- a/SessionNetworkingKit/SessionPro/Types/Plan.swift +++ b/SessionNetworkingKit/SessionPro/Types/Plan.swift @@ -4,7 +4,7 @@ import Foundation import SessionUtil public extension Network.SessionPro { - enum Plan: CaseIterable { + enum Plan: Sendable, Equatable, Hashable, CaseIterable { case none case oneMonth case threeMonths diff --git a/SessionNetworkingKit/SessionPro/Types/ProProof.swift b/SessionNetworkingKit/SessionPro/Types/ProProof.swift index 56d00d815f..37fe43a7be 100644 --- a/SessionNetworkingKit/SessionPro/Types/ProProof.swift +++ b/SessionNetworkingKit/SessionPro/Types/ProProof.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct ProProof: Sendable, Codable, Equatable { + struct ProProof: Sendable, Codable, Equatable, Hashable { public let version: UInt8 public let genIndexHash: [UInt8] public let rotatingPubkey: [UInt8] diff --git a/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift b/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift index 6a48b68e03..289d7c3c1c 100644 --- a/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift +++ b/SessionNetworkingKit/SessionPro/Types/UserTransaction.swift @@ -6,13 +6,13 @@ import SessionUtilitiesKit public extension Network.SessionPro { struct UserTransaction: Equatable { - public let provider: PaymentProvider + public let provider: PaymentProvider? public let paymentId: String public let orderId: String // MARK: - Initialization - init (provider: PaymentProvider, paymentId: String, orderId: String) { + init(provider: PaymentProvider?, paymentId: String, orderId: String) { self.provider = provider self.paymentId = paymentId self.orderId = orderId @@ -28,7 +28,7 @@ public extension Network.SessionPro { func toLibSession() -> session_pro_backend_add_pro_payment_user_transaction { var result: session_pro_backend_add_pro_payment_user_transaction = session_pro_backend_add_pro_payment_user_transaction() - result.provider = provider.libSessionValue + result.provider = (provider?.libSessionValue ?? SESSION_PRO_BACKEND_PAYMENT_PROVIDER_NIL) result.set(\.payment_id, to: paymentId) result.payment_id_count = paymentId.count result.set(\.order_id, to: orderId) diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 26b02f17aa..a193c083d1 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -394,7 +394,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let currentUserSessionIds: Set = [userSessionId.hexString] /// Define the `displayNameRetriever` so it can be reused - let displayNameRetriever: (String, Bool) -> String? = { [dependencies] sessionId, isInMessageBody in + let displayNameRetriever: DisplayNameRetriever = { [dependencies] sessionId, inMessageBody in (dependencies .mutate(cache: .libSession) { cache in cache.profile( @@ -405,8 +405,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) }? .displayName( - for: threadVariant, - suppressId: !isInMessageBody /// Don't want to show the id in a PN unless it's part of the body + /// Don't want to show the id in a PN unless it's part of the body + includeSessionIdSuffix: (threadVariant == .community && inMessageBody) )) .defaulting(to: sessionId.truncated()) } @@ -680,7 +680,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension groupIdentitySeed: Data?, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) throws { typealias GroupInfo = ( wasMessageRequest: Bool, @@ -927,7 +927,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension threadVariant: SessionThread.Variant, messageInfo: MessageReceiveJob.Details.MessageInfo, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) throws { /// Since we are going to save the message and generate deduplication files we need to determine whether we would want /// to show the message in case it is a message request (this is done by checking if there are already any dedupe records @@ -1130,7 +1130,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension callMessage: CallMessage, sender: String, sentTimestampMs: UInt64, - displayNameRetriever: @escaping (String, Bool) -> String? + displayNameRetriever: @escaping DisplayNameRetriever ) { guard Preferences.isCallKitSupported else { return handleFailureForVoIP( @@ -1145,8 +1145,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension VoipPayloadKey.uuid.rawValue: callMessage.uuid, VoipPayloadKey.caller.rawValue: sender, VoipPayloadKey.timestamp.rawValue: sentTimestampMs, - VoipPayloadKey.contactName.rawValue: displayNameRetriever(sender, false) - .defaulting(to: sender.truncated(threadVariant: threadVariant)) + VoipPayloadKey.contactName.rawValue: ( + displayNameRetriever(sender, false) ?? + sender.truncated() + ) ] CXProvider.reportNewIncomingVoIPPushPayload(payload) { [weak self, dependencies] error in @@ -1170,7 +1172,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension _ notification: ProcessedNotification, threadVariant: SessionThread.Variant, callMessage: CallMessage, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) { let content: UNMutableNotificationContent = UNMutableNotificationContent() content.userInfo = [ NotificationUserInfoKey.isFromRemote: true ] diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index a003a5bd34..9782f5c6a9 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -756,9 +756,6 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { } @MainActor func numberOfCharactersLeft(for text: String) -> Int { - return LibSession.numberOfCharactersLeft( - for: text, - isSessionPro: dependencies[cache: .libSession].isSessionPro - ) + return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } } diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index 69246680d2..a1868e9849 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -56,17 +56,16 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private static let linkPreviewViewInset: CGFloat = 6 private static let thresholdForCharacterLimit: Int = 200 - private var disposables: Set = Set() private let imageDataManager: ImageDataManagerType private let linkPreviewManager: LinkPreviewManagerType private let didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? - private let displayNameRetriever: (String, Bool) -> String? private let onQuoteCancelled: (() -> Void)? private weak var delegate: InputViewDelegate? - private var sessionProState: SessionProCTAManagerType? + private var sessionProManager: SessionProUIManagerType? public var quoteViewModel: QuoteViewModel? { didSet { handleQuoteDraftChanged() } } public var linkPreviewViewModel: LinkPreviewViewModel? + private var proStatusObservationTask: Task? private var linkPreviewLoadTask: Task? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) @@ -236,21 +235,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele }() private lazy var quoteView: QuoteView = QuoteView( - viewModel: QuoteViewModel( - mode: .draft, - direction: .outgoing, - currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: nil, - displayNameRetriever: displayNameRetriever - ), + viewModel: .emptyDraft, dataManager: imageDataManager, onCancel: { [weak self] in self?.quoteViewModel = nil @@ -356,18 +341,16 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele public init( delegate: InputViewDelegate, - displayNameRetriever: @escaping (String, Bool) -> String?, imageDataManager: ImageDataManagerType, linkPreviewManager: LinkPreviewManagerType, - sessionProState: SessionProCTAManagerType?, + sessionProManager: SessionProUIManagerType?, onQuoteCancelled: (() -> Void)? = nil, didLoadLinkPreview: (@MainActor (LinkPreviewViewModel.LoadResult) -> Void)? ) { self.imageDataManager = imageDataManager self.linkPreviewManager = linkPreviewManager self.delegate = delegate - self.displayNameRetriever = displayNameRetriever - self.sessionProState = sessionProState + self.sessionProManager = sessionProManager self.didLoadLinkPreview = didLoadLinkPreview self.onQuoteCancelled = onQuoteCancelled @@ -375,16 +358,17 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele setUpViewHierarchy() - self.sessionProState?.isSessionProPublisher - .subscribe(on: DispatchQueue.main) - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { [weak self] isPro in + self.proStatusObservationTask = Task(priority: .userInitiated) { [weak self] in + guard let sessionProManager else { return } + + for await isPro in sessionProManager.currentUserIsPro { + await MainActor.run { [weak self] in + /// The pro badge is a button to prompt a pro upgrade so hide it when already pro self?.sessionProBadge.isHidden = isPro self?.updateNumberOfCharactersLeft((self?.inputTextView.text ?? "")) } - ) - .store(in: &disposables) + } + } } override init(frame: CGRect) { @@ -397,6 +381,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele deinit { linkPreviewLoadTask?.cancel() + proStatusObservationTask?.cancel() } private func setUpViewHierarchy() { @@ -628,8 +613,14 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele disabledInputLabel.accessibilityLabel = updatedInputState.messageAccessibility?.label disabledInputTapGestureRecognizer.isEnabled = (updatedInputState.inputs.isEmpty) - attachmentsButtonContainer.isHidden = !updatedInputState.inputs.contains(.attachments) - voiceMessageButtonContainer.isHidden = !updatedInputState.inputs.contains(.voiceMessages) + attachmentsButtonContainer.isHidden = ( + !updatedInputState.inputs.contains(.attachments) && + !updatedInputState.inputs.contains(.attachmentsDisabled) + ) + voiceMessageButtonContainer.isHidden = ( + !updatedInputState.inputs.contains(.voiceMessages) && + !updatedInputState.inputs.contains(.voiceMessagesDisabled) + ) attachmentsButton.isSoftDisabled = updatedInputState.inputs.contains(.attachmentsDisabled) voiceMessageButton.isSoftDisabled = updatedInputState.inputs.contains(.voiceMessagesDisabled) diff --git a/SessionUIKit/Components/LinkPreviewView.swift b/SessionUIKit/Components/LinkPreviewView.swift index aeb89c5f98..1ea463359f 100644 --- a/SessionUIKit/Components/LinkPreviewView.swift +++ b/SessionUIKit/Components/LinkPreviewView.swift @@ -5,8 +5,8 @@ import NVActivityIndicatorView // MARK: - LinkPreviewViewModel -public struct LinkPreviewViewModel { - public enum State { +public struct LinkPreviewViewModel: Sendable, Equatable, Hashable { + public enum State: Sendable, Equatable, Hashable { case loading case draft case sent diff --git a/SessionUIKit/Components/QuoteView.swift b/SessionUIKit/Components/QuoteView.swift index b8391cbf48..4948c3414f 100644 --- a/SessionUIKit/Components/QuoteView.swift +++ b/SessionUIKit/Components/QuoteView.swift @@ -94,7 +94,7 @@ public final class QuoteView: UIView { imageView.center(in: imageContainerView) // Generate the thumbnail if needed - if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + if let source: ImageDataManager.DataSource = viewModel.quotedInfo?.attachmentInfo?.thumbnailSource { imageView.loadImage(source) { [weak imageView] buffer in guard buffer != nil else { return } @@ -125,11 +125,11 @@ public final class QuoteView: UIView { proBadgeThemeBackgroundColor: viewModel.proBadgeThemeColor ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = viewModel.author + authorLabel.text = (viewModel.quotedInfo?.authorName ?? "") authorLabel.themeTextColor = viewModel.targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.numberOfLines = 1 - authorLabel.isHidden = (viewModel.author == nil) + authorLabel.isHidden = (viewModel.quotedInfo == nil) authorLabel.isProBadgeHidden = !viewModel.showProBadge authorLabel.setCompressionResistance(.vertical, to: .required) diff --git a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift index bc9198cb39..64db1d918d 100644 --- a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift +++ b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift @@ -24,6 +24,7 @@ public struct AnimatedToggle: View { Toggle("", isOn: $uiValue) .labelsHidden() .accessibility(accessibility) + .tint(themeColor: .primary) .task { guard (oldValue ?? value) != value else { return } try? await Task.sleep(nanoseconds: 10_000_000) // ~10ms diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index a0324e8cdd..463f864025 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -495,7 +495,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic, dataManager: ImageDataManager(), - sessionProUIManager: NoopSessionProUIManager(isPro: false), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -507,7 +507,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic, dataManager: ImageDataManager(), - sessionProUIManager: NoopSessionProUIManager(isPro: false), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -519,7 +519,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic, dataManager: ImageDataManager(), - sessionProUIManager: NoopSessionProUIManager(isPro: false), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) @@ -531,7 +531,7 @@ struct ProCTAModal_Previews: PreviewProvider { ProCTAModal( variant: .generic, dataManager: ImageDataManager(), - sessionProUIManager: NoopSessionProUIManager(isPro: false), + sessionProUIManager: NoopSessionProUIManager(), dismissType: .single, afterClosed: nil ) diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift index c948539310..b7117a8c99 100644 --- a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -3,10 +3,36 @@ import SwiftUI import UniformTypeIdentifiers -public struct QuoteViewModel: Equatable, Hashable { - public enum Mode: Equatable, Hashable { case regular, draft } - public enum Direction: Equatable, Hashable { case incoming, outgoing } - public struct AttachmentInfo: Equatable, Hashable { +public struct QuoteViewModel: Sendable, Equatable, Hashable { + public enum Mode: Sendable, Equatable, Hashable { case regular, draft } + public enum Direction: Sendable, Equatable, Hashable { case incoming, outgoing } + + public struct QuotedInfo: Sendable, Equatable, Hashable { + public let interactionId: Int64 + public let authorId: String + public let authorName: String + public let timestampMs: Int64 + public let body: String? + public let attachmentInfo: AttachmentInfo? + + public init( + interactionId: Int64, + authorId: String, + authorName: String, + timestampMs: Int64, + body: String?, + attachmentInfo: AttachmentInfo? + ) { + self.interactionId = interactionId + self.authorId = authorId + self.authorName = authorName + self.timestampMs = timestampMs + self.body = body + self.attachmentInfo = attachmentInfo + } + } + + public struct AttachmentInfo: Sendable, Equatable, Hashable { public let id: String public let utType: UTType public let isVoiceMessage: Bool @@ -31,35 +57,28 @@ public struct QuoteViewModel: Equatable, Hashable { } } + public static let emptyDraft: QuoteViewModel = QuoteViewModel( + mode: .draft, + direction: .outgoing, + quotedInfo: nil, + showProBadge: false, + currentUserSessionIds: [], + displayNameRetriever: { _, _ in nil } + ) + public let mode: Mode public let direction: Direction - public let currentUserSessionIds: Set - public let rowId: Int64 - public let interactionId: Int64? - public let authorId: String + public let targetThemeColor: ThemeValue + public let quotedInfo: QuotedInfo? public let showProBadge: Bool - public let timestampMs: Int64 - public let quotedInteractionId: Int64 - public let quotedInteractionIsDeleted: Bool - public let quotedText: String? - public let quotedAttachmentInfo: AttachmentInfo? - let displayNameRetriever: (String, Bool) -> String? + public let attributedText: ThemedAttributedString // MARK: - Computed Properties - var hasAttachment: Bool { quotedAttachmentInfo != nil } - var author: String? { - guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } - guard quotedText != nil else { - // When we can't find the quoted message we want to hide the author label - return displayNameRetriever(authorId, false) - } - - return (displayNameRetriever(authorId, false) ?? authorId.truncated()) - } + var hasAttachment: Bool { quotedInfo?.attachmentInfo != nil } var fallbackImage: UIImage? { - guard let utType: UTType = quotedAttachmentInfo?.utType else { return nil } + guard let utType: UTType = quotedInfo?.attachmentInfo?.utType else { return nil } let fallbackImageName: String = (utType.conforms(to: .audio) ? "attachment_audio" : "actionsheet_document_black") @@ -70,17 +89,6 @@ public struct QuoteViewModel: Equatable, Hashable { return image } - var targetThemeColor: ThemeValue { - switch mode { - case .draft: return .textPrimary - case .regular: - return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - } - } - var proBadgeThemeColor: ThemeValue { switch mode { case .draft: return .primary @@ -103,18 +111,34 @@ public struct QuoteViewModel: Equatable, Hashable { } } - - var mentionLocation: MentionUtilities.MentionLocation { - switch (mode, direction) { - case (.draft, _): return .quoteDraft - case (_, .outgoing): return .outgoingQuote - case (_, .incoming): return .incomingQuote - } - } - var attributedText: ThemedAttributedString? { + // MARK: - Initialization + + public init( + mode: Mode, + direction: Direction, + quotedInfo: QuotedInfo?, + showProBadge: Bool, + currentUserSessionIds: Set, + displayNameRetriever: @escaping DisplayNameRetriever + ) { + self.mode = mode + self.direction = direction + self.quotedInfo = quotedInfo + self.showProBadge = showProBadge + self.targetThemeColor = { + switch mode { + case .draft: return .textPrimary + case .regular: + return (direction == .outgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + } + }() + let text: String = { - switch (quotedText, quotedAttachmentInfo) { + switch (quotedInfo?.body, quotedInfo?.attachmentInfo) { case (.some(let text), _) where !text.isEmpty: return text case (_, .some(let info)): return info.utType.shortDescription(isVoiceMessage: info.isVoiceMessage) @@ -123,10 +147,16 @@ public struct QuoteViewModel: Equatable, Hashable { } }() - return MentionUtilities.highlightMentions( + self.attributedText = MentionUtilities.highlightMentions( in: text, currentUserSessionIds: currentUserSessionIds, - location: mentionLocation, + location: { + switch (mode, direction) { + case (.draft, _): return .quoteDraft + case (_, .outgoing): return .outgoingQuote + case (_, .incoming): return .incomingQuote + } + }(), textColor: targetThemeColor, attributes: [ .themeForegroundColor: targetThemeColor, @@ -136,54 +166,22 @@ public struct QuoteViewModel: Equatable, Hashable { ) } - // MARK: - Initialization - - public init( - mode: Mode, - direction: Direction, - currentUserSessionIds: Set, - rowId: Int64, - interactionId: Int64?, - authorId: String, - showProBadge: Bool, - timestampMs: Int64, - quotedInteractionId: Int64, - quotedInteractionIsDeleted: Bool, - quotedText: String?, - quotedAttachmentInfo: AttachmentInfo?, - displayNameRetriever: @escaping (String, Bool) -> String? - ) { - self.mode = mode - self.direction = direction - self.currentUserSessionIds = currentUserSessionIds - self.rowId = rowId - self.interactionId = interactionId - self.authorId = authorId - self.showProBadge = showProBadge - self.timestampMs = timestampMs - self.quotedInteractionId = quotedInteractionId - self.quotedInteractionIsDeleted = quotedInteractionIsDeleted - self.quotedText = quotedText - self.quotedAttachmentInfo = quotedAttachmentInfo - self.displayNameRetriever = displayNameRetriever - } - public init(previewBody: String) { - self.quotedText = previewBody + self.quotedInfo = QuotedInfo( + interactionId: 0, + authorId: "", + authorName: "", + timestampMs: 0, + body: previewBody, + attachmentInfo: nil + ) /// This is an preview version so none of these values matter self.mode = .regular self.direction = .incoming - self.currentUserSessionIds = [] - self.rowId = -1 - self.interactionId = nil - self.authorId = "" + self.targetThemeColor = .messageBubble_incomingText self.showProBadge = false - self.timestampMs = 0 - self.quotedInteractionId = 0 - self.quotedInteractionIsDeleted = false - self.quotedAttachmentInfo = nil - self.displayNameRetriever = { _, _ in nil } + self.attributedText = ThemedAttributedString(string: previewBody) } // MARK: - Conformance @@ -192,30 +190,16 @@ public struct QuoteViewModel: Equatable, Hashable { return ( lhs.mode == rhs.mode && lhs.direction == rhs.direction && - lhs.currentUserSessionIds == rhs.currentUserSessionIds && - lhs.rowId == rhs.rowId && - lhs.interactionId == rhs.interactionId && - lhs.authorId == rhs.authorId && - lhs.timestampMs == rhs.timestampMs && - lhs.quotedInteractionId == rhs.quotedInteractionId && - lhs.quotedInteractionIsDeleted == rhs.quotedInteractionIsDeleted && - lhs.quotedText == rhs.quotedText && - lhs.quotedAttachmentInfo == rhs.quotedAttachmentInfo + lhs.quotedInfo == rhs.quotedInfo && + lhs.attributedText == rhs.attributedText ) } public func hash(into hasher: inout Hasher) { mode.hash(into: &hasher) direction.hash(into: &hasher) - currentUserSessionIds.hash(into: &hasher) - rowId.hash(into: &hasher) - interactionId?.hash(into: &hasher) - authorId.hash(into: &hasher) - timestampMs.hash(into: &hasher) - quotedInteractionId.hash(into: &hasher) - quotedInteractionIsDeleted.hash(into: &hasher) - quotedText.hash(into: &hasher) - quotedAttachmentInfo.hash(into: &hasher) + quotedInfo.hash(into: &hasher) + attributedText.hash(into: &hasher) } } @@ -257,7 +241,7 @@ public struct QuoteView_SwiftUI: View { height: Self.thumbnailSize ) - if let source: ImageDataManager.DataSource = viewModel.quotedAttachmentInfo?.thumbnailSource { + if let source: ImageDataManager.DataSource = viewModel.quotedInfo?.attachmentInfo?.thumbnailSource { SessionAsyncImage( source: source, dataManager: dataManager @@ -310,15 +294,15 @@ public struct QuoteView_SwiftUI: View { alignment: .leading, spacing: Self.labelStackViewSpacing ) { - if let author: String = viewModel.author { - Text(author) + if let authorName: String = viewModel.quotedInfo?.authorName { + Text(authorName) .bold() .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: viewModel.targetThemeColor) } - if let attributedText: ThemedAttributedString = viewModel.attributedText { - AttributedText(attributedText) + if viewModel.quotedInfo != nil { + AttributedText(viewModel.attributedText) .lineLimit(2) } else { Text("messageErrorOriginal".localized()) @@ -364,16 +348,16 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .draft, direction: .outgoing, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "Test User", + timestampMs: 0, + body: nil, + attachmentInfo: nil + ), + showProBadge: true, currentUserSessionIds: ["05123"], - rowId: 0, - interactionId: nil, - authorId: "05123", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: nil, displayNameRetriever: { _, _ in nil } ), dataManager: ImageDataManager() @@ -392,16 +376,16 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .draft, direction: .outgoing, + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "0512...1234", + timestampMs: 0, + body: "This was a message", + attachmentInfo: nil + ), + showProBadge: false, currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "05123", - showProBadge: true, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: "This was a message", - quotedAttachmentInfo: nil, displayNameRetriever: { _, _ in "Some User" } ), dataManager: ImageDataManager() @@ -420,23 +404,23 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { viewModel: QuoteViewModel( mode: .regular, direction: .incoming, - currentUserSessionIds: [], - rowId: 0, - interactionId: nil, - authorId: "", - showProBadge: false, - timestampMs: 0, - quotedInteractionId: 0, - quotedInteractionIsDeleted: false, - quotedText: nil, - quotedAttachmentInfo: QuoteViewModel.AttachmentInfo( - id: "", - utType: .wav, - isVoiceMessage: false, - downloadUrl: nil, - sourceFilename: nil, - thumbnailSource: nil + quotedInfo: QuoteViewModel.QuotedInfo( + interactionId: 0, + authorId: "05123", + authorName: "Name", + timestampMs: 0, + body: nil, + attachmentInfo: QuoteViewModel.AttachmentInfo( + id: "", + utType: .wav, + isVoiceMessage: false, + downloadUrl: nil, + sourceFilename: nil, + thumbnailSource: nil + ) ), + showProBadge: false, + currentUserSessionIds: [], displayNameRetriever: { _, _ in nil } ), dataManager: ImageDataManager() diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index a89ca6fe70..8e248669ef 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -186,7 +186,7 @@ public struct UserProfileModal: View { case (.some(let sessionId), .some): return ("accountId".localized(), sessionId.splitIntoLines(charactersForLines: [23, 23, 20])) case (.none, .some(let blindedId)): - return ("blindedId".localized(), blindedId) + return ("blindedId".localized(), blindedId.truncated(prefix: 10, suffix: 10)) case (.none, .none): return ("", "") // Shouldn't happen } @@ -259,7 +259,7 @@ public struct UserProfileModal: View { } .padding(.bottom, 12) } else { - if !info.isMessageRequestsEnabled, let displayName: String = info.displayName { + if !info.areMessageRequestsEnabled, let displayName: String = info.displayName { AttributedText( "messageRequestsTurnedOff" .put(key: "name", value: displayName) @@ -277,17 +277,17 @@ public struct UserProfileModal: View { } label: { Text("message".localized()) .font(.Body.baseBold) - .foregroundColor(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_text : .disabled)) + .foregroundColor(themeColor: (info.areMessageRequestsEnabled ? .sessionButton_text : .disabled)) .overlay( Capsule() - .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) + .stroke(themeColor: (info.areMessageRequestsEnabled ? .sessionButton_border : .disabled)) .frame( width: (geometry.size.width - Values.mediumSpacing) / 2, height: Values.smallButtonHeight ) ) } - .disabled(!info.isMessageRequestsEnabled) + .disabled(!info.areMessageRequestsEnabled) .buttonStyle(PlainButtonStyle()) } .frame( @@ -396,9 +396,9 @@ public extension UserProfileModal { let displayName: String? let contactDisplayName: String? let shouldShowProBadge: Bool - let isMessageRequestsEnabled: Bool - let onStartThread: (() -> Void)? - let onProBadgeTapped: (() -> Void)? + let areMessageRequestsEnabled: Bool + let onStartThread: (@MainActor () -> Void)? + let onProBadgeTapped: (@MainActor () -> Void)? public init( sessionId: String?, @@ -408,9 +408,9 @@ public extension UserProfileModal { displayName: String?, contactDisplayName: String?, shouldShowProBadge: Bool, - isMessageRequestsEnabled: Bool, - onStartThread: (() -> Void)?, - onProBadgeTapped: (() -> Void)? + areMessageRequestsEnabled: Bool, + onStartThread: (@MainActor () -> Void)?, + onProBadgeTapped: (@MainActor () -> Void)? ) { self.sessionId = sessionId self.blindedId = blindedId @@ -419,7 +419,7 @@ public extension UserProfileModal { self.displayName = displayName self.contactDisplayName = contactDisplayName self.shouldShowProBadge = shouldShowProBadge - self.isMessageRequestsEnabled = isMessageRequestsEnabled + self.areMessageRequestsEnabled = areMessageRequestsEnabled self.onStartThread = onStartThread self.onProBadgeTapped = onProBadgeTapped } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index e9f22821e6..fe02ccc2e1 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -26,94 +26,111 @@ public extension SessionProPaymentScreenContent { var description: ThemedAttributedString { switch self { - case .purchase: - "proChooseAccess" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) - case .update(let currentPlan, let expiredOn, let isAutoRenewing, _): - isAutoRenewing ? - "proAccessActivatesAuto" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + case .purchase: + return "proChooseAccess" .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.baseRegular) : - "proAccessActivatedNotAuto" + .localizedFormatted(Fonts.Body.baseRegular) + + case .update(let currentPlan, let expiredOn, let isAutoRenewing, _): + guard isAutoRenewing else { + return "proAccessActivatedNotAuto" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.baseRegular) + } + + return "proAccessActivatesAuto" + .put(key: "current_plan_length", value: currentPlan.durationString) .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular) - case .renew: - "proAccessRenewStart" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.baseRegular) - case .refund: - "proRefundDescription" - .localizedFormatted(baseFont: Fonts.Body.baseRegular) - case .cancel: - "proCancelSorry" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(baseFont: Fonts.Body.baseRegular) + + case .renew: + return "proAccessRenewStart" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.baseRegular) + + case .refund: + return "proRefundDescription" + .localizedFormatted(baseFont: Fonts.Body.baseRegular) + + case .cancel: + return "proCancelSorry" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(baseFont: Fonts.Body.baseRegular) } } } enum ClientPlatform: Equatable { case iOS - case Android + case android public var store: String { switch self { case .iOS: return Constants.platform_store - case .Android: return Constants.android_platform_store + case .android: return Constants.android_platform_store } } public var account: String { switch self { case .iOS: return Constants.platform_account - case .Android: return Constants.android_platform_account + case .android: return Constants.android_platform_account } } public var deviceType: String { switch self { case .iOS: return Constants.platform - case .Android: return Constants.android_platform + case .android: return Constants.android_platform } } public var name: String { switch self { case .iOS: return Constants.platform_name - case .Android: return Constants.android_platform_name + case .android: return Constants.android_platform_name } } } struct SessionProPlanInfo: Equatable { public let duration: Int + let totalPrice: Double + let pricePerMonth: Double + let discountPercent: Int? + let titleWithPrice: String + let subtitleWithPrice: String + var durationString: String { + let components = DateComponents(month: self.duration) let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.allowedUnits = [.month] - let components = DateComponents(month: self.duration) + return (formatter.string(from: components) ?? "\(self.duration) Months") } + var durationStringSingular: String { + let components = DateComponents(month: self.duration) let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.allowedUnits = [.month] formatter.maximumUnitCount = 1 - let components = DateComponents(month: self.duration) + return (formatter.string(from: components) ?? "\(self.duration) Month") } - let totalPrice: Double - let pricePerMonth: Double - let discountPercent: Int? - let titleWithPrice: String - let subtitleWithPrice: String - public init(duration: Int, totalPrice: Double, pricePerMonth: Double, discountPercent: Int?, titleWithPrice: String, subtitleWithPrice: String) { + public init( + duration: Int, + totalPrice: Double, + pricePerMonth: Double, + discountPercent: Int?, + titleWithPrice: String, + subtitleWithPrice: String + ) { self.duration = duration self.totalPrice = totalPrice self.pricePerMonth = pricePerMonth diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index bd5739234d..170bf27b8c 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -113,7 +113,7 @@ public protocol ThemedNavigation { // MARK: - ThemeValue -public indirect enum ThemeValue: Hashable, Equatable { +public indirect enum ThemeValue: Sendable, Hashable, Equatable { case value(ThemeValue, alpha: CGFloat) case explicitPrimary(Theme.PrimaryColor) case dynamicForInterfaceStyle(light: ThemeValue, dark: ThemeValue) diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index d34160e8d5..0547af74dd 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -84,9 +84,9 @@ final public class LocalizationHelper: CustomStringConvertible { // Replace html tag "
" with "\n" localizedString = localizedString.replacingOccurrences(of: "
", with: "\n") - // Add RTL mark for RTL-dominant strings to try to ensure proper rendering when starting/ending - // with English variables - if localizedString.isMostlyRTL { + // Add RTL mark for strings containing RTL characters\ to try to ensure proper rendering when + // starting/ending with English variables + if localizedString.containsRTL { localizedString = "\u{200F}" + localizedString + "\u{200F}" } @@ -154,21 +154,32 @@ public extension String { } private extension String { - /// Determines if the string's dominant language is Right-to-Left (RTL). + /// Determines if a string contains Right-to-Left (RTL) characters. /// - /// This uses `NLLanguageRecognizer` to find the string's dominant language - /// and then checks that language's character direction using `Locale`. + /// Rather than using `NLLanguageRecognizer` to find the string's dominant language (and then that languages direction using + /// `Locale`) this logic makes the assumption that if a string contains _any_ RTL charcters then the entire string should probably + /// be RTL (as it's unlikely we wouldn't want that). /// - /// - Returns: `true` if the dominant language is RTL (e.g., Arabic, Hebrew); - /// otherwise, `false`. - var isMostlyRTL: Bool { - let recognizer: NLLanguageRecognizer = NLLanguageRecognizer() - recognizer.processString(self) - - guard let language: NLLanguage = recognizer.dominantLanguage else { - return false // If no dominant language is recognized, assume not RTL. + /// **Note:** While using `NLLanguageRecognizer` might be "more correct", it performs I/O so when this runs on the main + /// thread it could result in lag + var containsRTL: Bool { + return unicodeScalars.contains { scalar in + switch scalar.value { + case 0x0590...0x05FF: return true // Hebrew + + // Arabic (also covers Persian, Urdu, Pashto, Sorani Kurdish) + case 0x0600...0x06FF, // Arabic + Persian/Urdu/Pashto extensions + 0x0750...0x077F, // Arabic Supplement + 0x08A0...0x08FF: // Arabic Extended-A + return true + + // Presentation forms (used by all Arabic-script languages) + case 0xFB1D...0xFDFF, // Hebrew + Arabic presentation forms + 0xFE70...0xFEFF: // Arabic Presentation Forms-B + return true + + default: return false + } } - // Check the character direction for the determined dominant language. - return (Locale.characterDirection(forLanguage: language.rawValue) == .rightToLeft) } } diff --git a/SessionUIKit/Types/SessionProUIManagerType.swift b/SessionUIKit/Types/SessionProUIManagerType.swift index 29af7fdef3..e3bd5e8b29 100644 --- a/SessionUIKit/Types/SessionProUIManagerType.swift +++ b/SessionUIKit/Types/SessionProUIManagerType.swift @@ -3,6 +3,7 @@ import UIKit public protocol SessionProUIManagerType: Actor { + nonisolated var characterLimit: Int { get } nonisolated var pinnedConversationLimit: Int { get } nonisolated var currentUserIsCurrentlyPro: Bool { get } nonisolated var currentUserIsPro: AsyncStream { get } @@ -13,7 +14,6 @@ public protocol SessionProUIManagerType: Actor { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool @@ -24,14 +24,12 @@ public protocol SessionProUIManagerType: Actor { public extension SessionProUIManagerType { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { showSessionProCTAIfNeeded( variant, dismissType: .recursive, - beforePresented: beforePresented, afterClosed: afterClosed, presenting: presenting ) @@ -44,7 +42,6 @@ public extension SessionProUIManagerType { showSessionProCTAIfNeeded( variant, dismissType: .recursive, - beforePresented: nil, afterClosed: nil, presenting: presenting ) @@ -55,13 +52,21 @@ public extension SessionProUIManagerType { internal actor NoopSessionProUIManager: SessionProUIManagerType { private let isPro: Bool + nonisolated public let characterLimit: Int + nonisolated public let pinnedConversationLimit: Int nonisolated public let currentUserIsCurrentlyPro: Bool nonisolated public var currentUserIsPro: AsyncStream { AsyncStream(unfolding: { return self.isPro }) } - init(isPro: Bool) { + init( + isPro: Bool = false, + characterLimit: Int = 2000, + pinnedConversationLimit: Int = 5 + ) { self.isPro = isPro + self.characterLimit = characterLimit + self.pinnedConversationLimit = pinnedConversationLimit self.currentUserIsCurrentlyPro = isPro } @@ -74,7 +79,6 @@ internal actor NoopSessionProUIManager: SessionProUIManagerType { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 7e07e35772..df4c15b1b4 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -3,6 +3,8 @@ import Foundation import UIKit +public typealias DisplayNameRetriever = (_ sessionId: String, _ inMessageBody: Bool) -> String? + public enum MentionUtilities { private static let currentUserCacheKey: String = "Mention.CurrentUser" // stringlint:ignore private static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) @@ -27,7 +29,7 @@ public enum MentionUtilities { public static func getMentions( in string: String, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> (String, [(range: NSRange, profileId: String, isCurrentUser: Bool)]) { var string = string var lastMatchEnd: Int = 0 @@ -71,7 +73,7 @@ public enum MentionUtilities { public static func highlightMentionsNoAttributes( in string: String, currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> String { let (string, _) = getMentions( in: string, @@ -88,7 +90,7 @@ public enum MentionUtilities { location: MentionLocation, textColor: ThemeValue, attributes: [NSAttributedString.Key: Any], - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> ThemedAttributedString { let (string, mentions) = getMentions( in: string, @@ -168,7 +170,7 @@ public enum MentionUtilities { public extension String { func replacingMentions( currentUserSessionIds: Set, - displayNameRetriever: (String, Bool) -> String? + displayNameRetriever: DisplayNameRetriever ) -> String { return MentionUtilities.highlightMentionsNoAttributes( in: self, diff --git a/SessionUtilitiesKit/Database/Types/PagedData.swift b/SessionUtilitiesKit/Database/Types/PagedData.swift index e637935a02..a5fbf78b67 100644 --- a/SessionUtilitiesKit/Database/Types/PagedData.swift +++ b/SessionUtilitiesKit/Database/Types/PagedData.swift @@ -385,8 +385,8 @@ public extension PagedData.LoadedInfo { /// If the `targetIndex` is over a page before the current content or more than a page after the current content /// then we want to reload the entire content (to avoid loading an excessive amount of data), otherwise we should /// load all messages between the current content and the `targetIndex` (plus padding) - let isCloseBefore = targetIndex >= (firstPageOffset - pageSize) - let isCloseAfter = targetIndex <= (lastIndex + pageSize) + let isCloseBefore: Bool = (targetIndex >= (firstPageOffset - pageSize) && targetIndex < firstPageOffset) + let isCloseAfter: Bool = (targetIndex > lastIndex && targetIndex <= (lastIndex + pageSize)) if isCloseBefore { newOffset = max(0, targetIndex - padding) diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 51d90f0038..72fa7a143a 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -278,12 +278,15 @@ public protocol MockableFeatureValue: RawRepresentable, Sendable, Hashable, Equa extension MockableFeatureValue { public var rawValue: Int { - let all: [Self] = Array(Self.allCases) + let targetId: String = String(reflecting: self) - guard let index: Array.Index = all.firstIndex(of: self) else { return 0 } + for (index, element) in Self.allCases.enumerated() { + if String(reflecting: element) == targetId { + return index + 1 /// The `rawValue` is 1-indexed whereas the array is 0-indexed + } + } - /// The `rawValue` is 1-indexed whereas the array is 0-indexed - return index + 1 + return 0 /// Should theoretically never happen if self is in `allCases` } public init?(rawValue: Int) { diff --git a/SessionUtilitiesKit/General/ReusableView.swift b/SessionUtilitiesKit/General/ReusableView.swift new file mode 100644 index 0000000000..032b624c6d --- /dev/null +++ b/SessionUtilitiesKit/General/ReusableView.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public protocol ReusableView: AnyObject { + static var defaultReuseIdentifier: String { get } +} + +public extension ReusableView where Self: UIView { + static var defaultReuseIdentifier: String { + return String(describing: self.self) + } +} + +extension UICollectionReusableView: ReusableView {} +extension UITableViewCell: ReusableView {} +extension UITableViewHeaderFooterView: ReusableView {} diff --git a/SessionUtilitiesKit/Observations/ObservableKey.swift b/SessionUtilitiesKit/Observations/ObservableKey.swift index c0383a8ea6..ea70decce0 100644 --- a/SessionUtilitiesKit/Observations/ObservableKey.swift +++ b/SessionUtilitiesKit/Observations/ObservableKey.swift @@ -5,21 +5,42 @@ import Foundation public struct GenericObservableKey: Setting.Key, Sendable { public let rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue } - public init(_ original: ObservableKey) { self.rawValue = original.rawValue } + public init(_ original: ObservableKey) { self.rawValue = original.generic.rawValue } } public struct ObservableKey: Setting.Key, Sendable { public let rawValue: String public let generic: GenericObservableKey + internal let streamSource: ExternalStreamSource? public init(_ rawValue: String) { self.rawValue = rawValue self.generic = GenericObservableKey(rawValue) + self.streamSource = nil } public init(_ rawValue: String, _ generic: GenericObservableKey?) { self.rawValue = rawValue self.generic = (generic ?? GenericObservableKey(rawValue)) + self.streamSource = nil + } + + private init(rawValue: String, generic: GenericObservableKey, streamSource: ExternalStreamSource) { + self.rawValue = rawValue + self.generic = generic + self.streamSource = streamSource + } + + public static func stream( + key: String, + generic: GenericObservableKey, + _ streamProvider: @escaping @Sendable () async -> AsyncStream? + ) -> ObservableKey { + return ObservableKey( + rawValue: key, + generic: generic, + streamSource: ExternalStreamSource(id: key, stream: streamProvider) + ) } } @@ -88,3 +109,36 @@ public struct AnySendableHashable: Hashable, Sendable { } } } + +public struct ExternalStreamSource: Sendable, Hashable { + public let id: String + internal let makeStream: @Sendable () async -> AsyncStream? + + public init( + id: String, + stream: @escaping @Sendable () async -> AsyncStream? + ) { + self.id = id + self.makeStream = { + guard let concreteStream = await stream() else { return nil } + + return AsyncStream { continuation in + let task = Task { + for await value in concreteStream { + continuation.yield(AnySendableHashable(value)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + } + + public static func == (lhs: ExternalStreamSource, rhs: ExternalStreamSource) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/SessionUtilitiesKit/Observations/ObservationBuilder.swift b/SessionUtilitiesKit/Observations/ObservationBuilder.swift index 8040e95524..1c07681024 100644 --- a/SessionUtilitiesKit/Observations/ObservationBuilder.swift +++ b/SessionUtilitiesKit/Observations/ObservationBuilder.swift @@ -7,6 +7,14 @@ import Combine public protocol ObservableKeyProvider: Sendable, Equatable { var observedKeys: Set { get } + + func observedKeys(using dependencies: Dependencies) -> Set +} + +public extension ObservableKeyProvider { + func observedKeys(using dependencies: Dependencies) -> Set { + return observedKeys + } } // MARK: - ObservationBuilder DSL @@ -225,7 +233,7 @@ private actor QueryRunner { /// Capture the updated data and new keys to observe let newResult: Output = await self.query(previousValueForQuery, eventsToProcess, isInitialQuery, dependencies) - let newKeys: Set = newResult.observedKeys + let newKeys: Set = newResult.observedKeys(using: dependencies) /// If the keys have changed then we need to restart the observation if newKeys != activeKeys { @@ -260,15 +268,28 @@ private actor QueryRunner { guard let self = self else { return } do { - let stream = await self.observationManager.observe(key) - - for await event in stream { - try Task.checkCancellation() + if let source = key.streamSource { + if let stream = await source.makeStream() { + for await value in stream { + try Task.checkCancellation() + + let event = ObservedEvent(key: key, value: value) + await self.debouncer.signal(event: event) + } + } + } + else { + let stream = await self.observationManager.observe(key) - switch event.priority { - case .standard: await self.debouncer.signal(event: event.event) - case .immediate: await self.debouncer.flush(event: event.event) + for await event in stream { + try Task.checkCancellation() + + switch event.priority { + case .standard: await self.debouncer.signal(event: event.event) + case .immediate: await self.debouncer.flush(event: event.event) + } } + } } catch { diff --git a/SessionUtilitiesKit/Observations/ObservationUtilities.swift b/SessionUtilitiesKit/Observations/ObservationUtilities.swift new file mode 100644 index 0000000000..de8067cee1 --- /dev/null +++ b/SessionUtilitiesKit/Observations/ObservationUtilities.swift @@ -0,0 +1,90 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum EventDataRequirement { + case databaseQuery + case other + case bothDatabaseQueryAndOther +} + +public struct EventChangeset { + public let databaseEvents: Set + private let eventsByKey: [GenericObservableKey: [ObservedEvent]] + + fileprivate init( + databaseEvents: Set, + eventsByKey: [GenericObservableKey: [ObservedEvent]] + ) { + self.databaseEvents = databaseEvents + self.eventsByKey = eventsByKey + } + + // MARK: - Accessors + + /// Checks if any event matches the generic key + public func contains(_ key: GenericObservableKey) -> Bool { + return eventsByKey[key] != nil + } + + /// Returns the most recent value for a specific key, cast to T + public func latest(_ key: GenericObservableKey, as type: T.Type = T.self) -> T? { + return eventsByKey[key]?.last?.value as? T /// The `last` event should be the newest + } + + /// Iterates over all events matching the key, casting them to T + public func forEach( + _ key: GenericObservableKey, + as type: T.Type = T.self, + _ body: (T) -> Void + ) { + eventsByKey[key]?.forEach { event in + if let value = event.value as? T { + body(value) + } + } + } + + /// Iterates over events matching the key, providing the full event (useful if you need the specific key ID) + public func forEachEvent( + _ key: GenericObservableKey, + as valueType: T.Type = T.self, + _ body: (ObservedEvent, T) -> Void + ) { + eventsByKey[key]?.forEach { event in + if let value = event.value as? T { + body(event, value) + } + } + } +} + +public extension Collection where Element == ObservedEvent { + func split() -> EventChangeset { + var allEvents: [GenericObservableKey: [ObservedEvent]] = [:] + + for event in self { + allEvents[event.key.generic, default: []].append(event) + } + + return EventChangeset(databaseEvents: [], eventsByKey: allEvents) + } + + func split( + by classifier: (ObservedEvent) -> EventDataRequirement + ) -> EventChangeset { + var dbEvents: Set = [] + var allEvents: [GenericObservableKey: [ObservedEvent]] = [:] + + for event in self { + allEvents[event.key.generic, default: []].append(event) + + switch classifier(event) { + case .databaseQuery, .bothDatabaseQueryAndOther: dbEvents.insert(event) + case .other: break + } + } + + return EventChangeset(databaseEvents: dbEvents, eventsByKey: allEvents) + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 86d8772e8c..779a7cebe7 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -224,13 +224,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC private lazy var snInputView: InputView = { let result: InputView = InputView( delegate: self, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ), imageDataManager: dependencies[singleton: .imageDataManager], linkPreviewManager: dependencies[singleton: .linkPreviewManager], - sessionProState: dependencies[singleton: .sessionProState], + sessionProManager: dependencies[singleton: .sessionProManager], onQuoteCancelled: onQuoteCancelled, didLoadLinkPreview: { [weak self] result in self?.didLoadLinkPreview?(result) From 92dc30ffcb26e3a178d69c71cbdb03fedcea79cd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 26 Nov 2025 14:38:29 +1100 Subject: [PATCH 26/66] Bunch of process on pro integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added integration for new refund request • Wired up the refunding states based on the new data • Updated code to use strings from libSession instead of hardcoded values • Updated the code to trigger a pro state refresh when the `proAccessExpiryTimestampMs` on the config changes (ie. another device made a change to the pro configuration) • Updated logic to drive mocked pro states via the SessionProManager to make the code a bit cleaner • Updated the handling of pro proofs to sync both active and expired proofs (instead of just active) • Fixed a bunch of warnings --- Session.xcodeproj/project.pbxproj | 40 +- .../Conversations/ConversationViewModel.swift | 4 +- .../Settings/ThreadSettingsViewModel.swift | 2 +- .../App Review/AppReviewPromptModel.swift | 3 +- Session/Home/HomeViewModel.swift | 15 +- .../MessageInfoScreen.swift | 2 +- Session/Meta/Session+SNUIKit.swift | 11 + .../DeveloperSettingsProViewModel.swift | 89 ++-- .../DeveloperSettingsViewModel.swift | 12 + Session/Settings/NukeDataModal.swift | 4 +- .../SessionProSettingsViewModel.swift | 438 +++++++++++------- Session/Settings/SettingsViewModel.swift | 43 +- .../Database/Models/Profile.swift | 1 - .../Config Handling/LibSession+Contacts.swift | 17 +- .../LibSession+UserProfile.swift | 13 +- .../MessageReceiver+Groups.swift | 6 +- .../MessageReceiver+MessageRequests.swift | 2 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../Sending & Receiving/MessageReceiver.swift | 15 +- .../SessionPro/SessionProManager.swift | 407 ++++++++++++---- .../Types/SessionProClientPlatform.swift | 67 --- .../SessionProDecodedProForMessage.swift | 6 - .../Types/SessionProDecodedStatus.swift | 17 +- .../Types/SessionProLoadingState.swift | 20 - .../SessionPro/Types/SessionProMetadata.swift | 73 +++ .../SessionPro/Types/SessionProPlan.swift | 7 +- .../Types/SessionProRefundingStatus.swift | 26 ++ .../Utilities/SessionProMocking.swift | 132 ++++++ .../Utilities/Profile+Updating.swift | 79 ++-- .../PushNotificationEndpoint.swift | 1 - .../SOGS/Models/PinnedMessage.swift | 2 +- .../SessionNetworkEndpoint.swift | 4 +- .../SetPaymentRefundRequestedRequest.swift | 43 ++ .../SetPaymentRefundRequestedResponse.swift | 48 ++ .../SessionPro/SessionProAPI.swift | 46 ++ .../SessionPro/SessionProEndpoint.swift | 2 + .../Types/BackendUserProStatus.swift | 20 - .../SessionPro/Types/PaymentItem.swift | 26 +- .../Types/PaymentProviderMetadata.swift | 43 -- .../ShareNavController.swift | 11 + SessionUIKit/Configuration.swift | 23 + .../SessionProPaymentScreen+CancelPlan.swift | 14 +- .../SessionProPaymentScreen+Models.swift | 41 +- .../SessionProPaymentScreen+Renew.swift | 20 +- ...essionProPaymentScreen+RequestRefund.swift | 22 +- .../SessionProPaymentScreen+UpdatePlan.swift | 14 +- .../SessionProPaymentScreen.swift | 6 +- .../SessionProSettings/SessionProUI.swift | 72 +++ .../Style Guide/Constants+Apple.swift | 28 +- .../Database/Types/Migration.swift | 2 +- SessionUtilitiesKit/General/ScreenLock.swift | 4 + .../Observations/ObservableKey.swift | 5 + SessionUtilitiesKit/Types/FileManager.swift | 71 ++- 53 files changed, 1393 insertions(+), 728 deletions(-) delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProClientPlatform.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift create mode 100644 SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedRequest.swift create mode 100644 SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift delete mode 100644 SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift create mode 100644 SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5bb35c39b6..06b84fb862 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -513,6 +513,12 @@ FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */; }; + FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */; }; + FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; + FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; + FD1F3CF32ED657AC00E536D5 /* SessionProMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */; }; + FD1F3CF62ED69B6600E536D5 /* SessionProMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */; }; + FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */; }; FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */; }; FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */; }; FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */; }; @@ -622,7 +628,6 @@ FD306BD42EB031C200ADB003 /* PaymentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */; }; FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */; }; FD306BD82EB033CD00ADB003 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD72EB033CB00ADB003 /* Plan.swift */; }; - FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */; }; FD306BDC2EB0436C00ADB003 /* GenerateProProofRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */; }; FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; @@ -670,7 +675,6 @@ FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */; }; FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */; }; FD360ED12ECFB8AC0050CAF4 /* SessionProExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */; }; - FD360ED32ECFBC890050CAF4 /* SessionProClientPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */; }; FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */; }; FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */; }; @@ -2021,6 +2025,12 @@ FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _029_BlockCommunityMessageRequests.swift; sourceTree = ""; }; + FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedRequest.swift; sourceTree = ""; }; + FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedResponse.swift; sourceTree = ""; }; + FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUI.swift; sourceTree = ""; }; + FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMetadata.swift; sourceTree = ""; }; + FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMocking.swift; sourceTree = ""; }; + FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProRefundingStatus.swift; sourceTree = ""; }; FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLeavingJob.swift; sourceTree = ""; }; FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckForAppUpdatesJob.swift; sourceTree = ""; }; @@ -2097,7 +2107,6 @@ FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatus.swift; sourceTree = ""; }; FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendUserProStatus.swift; sourceTree = ""; }; FD306BD72EB033CB00ADB003 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; - FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentProviderMetadata.swift; sourceTree = ""; }; FD306BDB2EB0436800ADB003 /* GenerateProProofRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateProProofRequest.swift; sourceTree = ""; }; FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSMKMockExtensions.swift; sourceTree = ""; }; FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomArgSummaryDescribable+SessionMessagingKit.swift"; sourceTree = ""; }; @@ -2119,7 +2128,6 @@ FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionSet+Utilities.swift"; sourceTree = ""; }; FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProLoadingState.swift; sourceTree = ""; }; FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExpiry.swift; sourceTree = ""; }; - FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProClientPlatform.swift; sourceTree = ""; }; FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationUtilities.swift; sourceTree = ""; }; FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPlan.swift; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; @@ -3130,6 +3138,7 @@ 9438D5542E6A6843008C7FFE /* SessionProSettings */ = { isa = PBXGroup; children = ( + FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */, 9438D5562E6A6862008C7FFE /* SessionProPaymentScreen.swift */, 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */, 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */, @@ -4341,7 +4350,6 @@ FD306BD52EB0322E00ADB003 /* BackendUserProStatus.swift */, FD306BD12EB031AB00ADB003 /* PaymentItem.swift */, FD0F85652EA82FC9004E0B98 /* PaymentProvider.swift */, - FD306BD92EB0359600ADB003 /* PaymentProviderMetadata.swift */, FD306BD32EB031BF00ADB003 /* PaymentStatus.swift */, FD306BD72EB033CB00ADB003 /* Plan.swift */, FD0F85762EA83D8F004E0B98 /* ProProof.swift */, @@ -4365,6 +4373,8 @@ FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */, FD360EC02ECD23950050CAF4 /* GetProRevocationsRequest.swift */, FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */, + FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */, + FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */, ); path = Requests; sourceTree = ""; @@ -4478,6 +4488,14 @@ path = Database; sourceTree = ""; }; + FD1F3CF42ED69B5B00E536D5 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FD2272842C33E28D004D8A6C /* StorageServer */ = { isa = PBXGroup; children = ( @@ -5307,6 +5325,7 @@ isa = PBXGroup; children = ( FDAA36C42EB474B50040603E /* Types */, + FD1F3CF42ED69B5B00E536D5 /* Utilities */, 94B6BAF52E30A88800E718BB /* SessionProManager.swift */, ); path = SessionPro; @@ -5315,14 +5334,15 @@ FDAA36C42EB474B50040603E /* Types */ = { isa = PBXGroup; children = ( - FD360ED22ECFBC820050CAF4 /* SessionProClientPlatform.swift */, FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, FDAA36C92EB476060040603E /* SessionProFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, + FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, + FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */, FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, ); @@ -6778,6 +6798,7 @@ 94363E662E60186A0004EE43 /* SessionListScreen+Section.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */, FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */, 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, @@ -6931,7 +6952,6 @@ FD47E0B52AA6D7AA00A55E41 /* Request+SnodeAPI.swift in Sources */, FD0F856B2EA83525004E0B98 /* AppProPaymentRequest.swift in Sources */, FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */, - FD306BDA2EB0359B00ADB003 /* PaymentProviderMetadata.swift in Sources */, FD5E93D12C100FD70038C25A /* FileUploadResponse.swift in Sources */, FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, FD02CC162C3681EF009AB976 /* RevokeSubaccountResponse.swift in Sources */, @@ -6946,6 +6966,7 @@ FD2272B02C33E337004D8A6C /* NetworkError.swift in Sources */, FD306BD62EB0323000ADB003 /* BackendUserProStatus.swift in Sources */, FD0F85772EA83D92004E0B98 /* ProProof.swift in Sources */, + FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */, FD6B92AB2E77A920004463B5 /* SOGS.swift in Sources */, FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */, FD6B92E92E77C5D1004463B5 /* SubscribeResponse.swift in Sources */, @@ -6988,6 +7009,7 @@ FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */, FD2272AF2C33E337004D8A6C /* JSON.swift in Sources */, FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */, + FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */, FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */, FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, @@ -7238,6 +7260,7 @@ FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */, FD360EBF2ECAD5190050CAF4 /* SessionProConfig.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, + FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */, FD2272FA2C352D8E004D8A6C /* LibSession+SharedGroup.swift in Sources */, FD37EA0F28AB3330003AE748 /* _014_FixHiddenModAdminSupport.swift in Sources */, FD2272772C32911C004D8A6C /* AttachmentUploadJob.swift in Sources */, @@ -7260,6 +7283,7 @@ FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, + FD1F3CF62ED69B6600E536D5 /* SessionProMocking.swift in Sources */, FDD23AEA2E458EB00057E853 /* _012_AddJobPriority.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, @@ -7322,7 +7346,6 @@ FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, - FD360ED32ECFBC890050CAF4 /* SessionProClientPlatform.swift in Sources */, FD245C55285065E500B966DD /* CommunityManager.swift in Sources */, FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, @@ -7362,6 +7385,7 @@ FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, + FD1F3CF32ED657AC00E536D5 /* SessionProMetadata.swift in Sources */, FDFE75B12ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, FD2273012C352D8E004D8A6C /* LibSession+Shared.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 8d57c60ba6..c242049306 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1282,8 +1282,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Generate the optimistic data let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages let currentState: State = await self.state - let proFeatures: SessionPro.Features = try await { - let userProfileFeatures: SessionPro.Features = await dependencies[singleton: .sessionProManager].proFeatures + let proFeatures: SessionPro.Features = try { + let userProfileFeatures: SessionPro.Features = (dependencies[singleton: .sessionProManager].currentUserCurrentProFeatures ?? .none) let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features( for: (text ?? ""), features: userProfileFeatures diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 86c787c57e..b1af8c2d04 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -2088,7 +2088,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit) ), dataManager: dependencies[singleton: .imageDataManager], - sessionProUIManager: dependencies[singleton: .sessionProManager] + sessionProUIManager: dependencies[singleton: .sessionProManager], onConfirm: { [dependencies] in // TODO: [PRO] Need to sort this out dependencies[singleton: .sessionProState].upgradeToPro( diff --git a/Session/Home/App Review/AppReviewPromptModel.swift b/Session/Home/App Review/AppReviewPromptModel.swift index c350d9dbaf..3d214db78c 100644 --- a/Session/Home/App Review/AppReviewPromptModel.swift +++ b/Session/Home/App Review/AppReviewPromptModel.swift @@ -2,6 +2,7 @@ import Foundation import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit struct AppReviewPromptModel { @@ -103,7 +104,7 @@ enum AppReviewPromptState { .localized(), message: "rateSessionModalDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "storevariant", value: Constants.platform_store) + .put(key: "storevariant", value: SessionPro.Metadata.appStore.store) .localized(), primaryButtonTitle: "rateSessionApp".localized(), primaryButtonAccessibilityIdentifier: "rate-app-button", diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index b965d90b9d..26092f9396 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -5,6 +5,7 @@ import Combine import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionNetworkingKit import SessionMessagingKit import SessionUtilitiesKit import StoreKit @@ -645,11 +646,13 @@ public class HomeViewModel: NavigatableStateHolder { } @MainActor func showSessionProCTAIfNeeded() async { - switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { - case .none, .neverBeenPro: - return + let status: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager].proStatus.first(defaultValue: nil) + let isRefunding: SessionPro.IsRefunding = await dependencies[singleton: .sessionProManager].isRefunding.first(defaultValue: .notRefunding) + + switch (status, isRefunding) { + case (.none, _), (.neverBeenPro, _), (.active, .refunding): return - case .active: + case (.active, .notRefunding): let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] .accessExpiryTimestampMs .first() @@ -668,7 +671,7 @@ public class HomeViewModel: NavigatableStateHolder { } ) - case .expired: + case (.expired, _): let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] .accessExpiryTimestampMs .first() @@ -764,7 +767,7 @@ public class HomeViewModel: NavigatableStateHolder { // stringlint:disable let surveyUrl: URL = url.appending(queryItems: [ - .init(name: "platform", value: Constants.platform_name), + .init(name: "platform", value: SessionPro.Metadata.appStore.device), .init(name: "version", value: dependencies[cache: .appVersion].appVersion) ]) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 6cfc128d46..5d6e16a22a 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -585,7 +585,7 @@ struct MessageInfoScreen: View { ), dataManager: viewModel.dependencies[singleton: .imageDataManager], sessionProUIManager: viewModel.dependencies[singleton: .sessionProManager], - onConfirm: { [dependencies] in + onConfirm: { [dependencies = viewModel.dependencies] in // TODO: [PRO] Need to sort this out dependencies[singleton: .sessionProState].upgradeToPro( plan: SessionProPlan(variant: .threeMonths), diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 5bcfc4e3dd..0826ccb467 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -123,4 +123,15 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { @MainActor func numberOfCharactersLeft(for text: String) -> Int { return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } + + func proUrlStringProvider() -> SessionProUI.UrlStringProvider { + return SessionPro.Metadata.urls + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + switch platform { + case .iOS: return SessionPro.Metadata.appStore + case .android: return SessionPro.Metadata.playStore + } + } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 4a2454cdca..a471689777 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -49,12 +49,14 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum Section: SessionTableSection { case general case subscriptions + case proBackend case features var title: String? { switch self { case .general: return nil case .subscriptions: return "Subscriptions" + case .proBackend: return "Pro Backend" case .features: return "Features" } } @@ -72,6 +74,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case mockCurrentUserSessionProBackendStatus case mockCurrentUserSessionProLoadingState + case mockCurrentUserSessionProOriginatingPlatform case proBadgeEverywhere case fakeAppleSubscriptionForDev @@ -88,6 +91,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case manageProSubscriptions case restoreProSubscription case requestRefund + case submitPurchaseToProBackend case refreshProState case removeProFromUserConfig @@ -102,6 +106,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .mockCurrentUserSessionProBackendStatus: return "mockCurrentUserSessionProBackendStatus" case .mockCurrentUserSessionProLoadingState: return "mockCurrentUserSessionProLoadingState" + case .mockCurrentUserSessionProOriginatingPlatform: return "mockCurrentUserSessionProOriginatingPlatform" case .proBadgeEverywhere: return "proBadgeEverywhere" case .fakeAppleSubscriptionForDev: return "fakeAppleSubscriptionForDev" @@ -118,6 +123,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" case .requestRefund: return "requestRefund" + case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" case .refreshProState: return "refreshProState" case .removeProFromUserConfig: return "removeProFromUserConfig" @@ -135,6 +141,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .mockCurrentUserSessionProBackendStatus: result.append(.mockCurrentUserSessionProBackendStatus); fallthrough case .mockCurrentUserSessionProLoadingState: result.append(.mockCurrentUserSessionProLoadingState); fallthrough + case .mockCurrentUserSessionProOriginatingPlatform: result.append(.mockCurrentUserSessionProOriginatingPlatform); fallthrough case .proBadgeEverywhere: result.append(.proBadgeEverywhere); fallthrough case .fakeAppleSubscriptionForDev: result.append(.fakeAppleSubscriptionForDev); fallthrough @@ -151,6 +158,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .requestRefund: result.append(.requestRefund); fallthrough + case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough case .refreshProState: result.append(.refreshProState); fallthrough case .removeProFromUserConfig: result.append(.removeProFromUserConfig) @@ -174,6 +182,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let mockCurrentUserSessionProBackendStatus: MockableFeature let mockCurrentUserSessionProLoadingState: MockableFeature + let mockCurrentUserSessionProOriginatingPlatform: MockableFeature let proBadgeEverywhere: Bool let fakeAppleSubscriptionForDev: Bool @@ -211,12 +220,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .feature(.mockCurrentUserSessionProBackendStatus), .feature(.mockCurrentUserSessionProLoadingState), + .feature(.mockCurrentUserSessionProOriginatingPlatform), .feature(.proBadgeEverywhere), .feature(.fakeAppleSubscriptionForDev), // .feature(.proPlanToRecover), // .feature(.mockCurrentUserSessionProExpiry), // .feature(.mockInstalledFromIPA), - // .feature(.proPlanOriginatingPlatform), .feature(.forceMessageFeatureProBadge), .feature(.forceMessageFeatureLongMessage), .feature(.forceMessageFeatureAnimatedAvatar), @@ -229,13 +238,13 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], // proPlanToRecover: dependencies[feature: .proPlanToRecover], // proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], // mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], -// originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], @@ -305,12 +314,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], // proPlanToRecover: dependencies[feature: .proPlanToRecover], // proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], // mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], -// originatingPlatform: dependencies[feature: .proPlanOriginatingPlatform], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], @@ -357,19 +366,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold // MARK: - Mockable Features - let mockedProStatus: String = { - switch state.mockCurrentUserSessionProBackendStatus { - case .simulate(let status): return "\(status)" - case .useActual: return "None" - } - }() - let mockedLoadingState: String = { - switch state.mockCurrentUserSessionProLoadingState { - case .simulate(let state): return "\(state)" - case .useActual: return "None" - } - }() - let features: SectionModel = SectionModel( model: .features, elements: [ @@ -379,7 +375,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Force the current users Session Pro to a specific status locally. - Current: \(mockedProStatus) + Current: \(devValue: state.mockCurrentUserSessionProBackendStatus) """, trailingAccessory: .icon(.squarePen), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in @@ -404,7 +400,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Force the Session Pro UI into a specific loading state. - Current: \(mockedLoadingState) + Current: \(devValue: state.mockCurrentUserSessionProLoadingState) Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. """, @@ -431,6 +427,39 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } ), + SessionCell.Info( + id: .mockCurrentUserSessionProOriginatingPlatform, + title: "Mocked Originating Platform", + subtitle: """ + Force the current users Session Pro to have originated from a specific platform. + + Current: \(devValue: state.mockCurrentUserSessionProOriginatingPlatform) + + Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. + """, + trailingAccessory: .icon(.squarePen), + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Originating Platform", + explanation: "Force the current users Session Pro to have originated from a specific platform.", + feature: .mockCurrentUserSessionProOriginatingPlatform, + currentValue: state.mockCurrentUserSessionProOriginatingPlatform, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), SessionCell.Info( id: .proBadgeEverywhere, title: "Show the Pro Badge everywhere", @@ -565,7 +594,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold subtitle: """ Purchase Session Pro via the App Store. - Note: This only works on a real device (and some old iOS versions don't seem to support Sandbox accounts (eg. iOS 16). + Notes: + • This only works on a real device (and some old iOS versions don't seem to support Sandbox accounts (eg. iOS 16). + • This subscription isn't connected to the Session account by default (they are for testing purposes) Status: \(purchaseStatus) Product Name: \(productName) @@ -609,14 +640,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Status: \(refundStatus) """, trailingAccessory: .highlightingBackgroundLabel(title: "Request"), - isEnabled: ( - state.purchaseTransaction != nil || - state.fakeAppleSubscriptionForDev - ), + isEnabled: (state.purchaseTransaction != nil), onTap: { [weak viewModel] in Task { await viewModel?.requestRefund() } } - ), + ) + ] + ) + + let proBackend: SectionModel = SectionModel( + model: .proBackend, + elements: [ SessionCell.Info( id: .submitPurchaseToProBackend, title: "Submit Purchase to Pro Backend", @@ -843,7 +877,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold // ) // .compactMap { $0 } - return [general, features, subscriptions] + return [general, features, subscriptions, proBackend] } // MARK: - Functions @@ -1188,6 +1222,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { let result = try await transaction.beginRefundRequest(in: scene) + dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), value: DeveloperSettingsProEvent.refundTransaction(result) @@ -1232,7 +1267,9 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func refreshProState() async { do { try await dependencies[singleton: .sessionProManager].refreshProState() - let status: Network.SessionPro.BackendUserProStatus? = dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus + let status: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager] + .proStatus + .first(defaultValue: nil) dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 6d47bcf042..5d2494b42c 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -1974,7 +1974,19 @@ final class PollLimitInputView: UIView, UITextFieldDelegate, SessionCell.Accesso } } +// MARK: - Format Convenience + +internal extension String.StringInterpolation { + mutating func appendInterpolation(devValue: MockableFeature) { + switch devValue { + case .useActual: appendLiteral("None") + case .simulate(let value): appendLiteral("\(value)") + } + } +} + // MARK: - WarningVersion + struct WarningVersion: Listable { var version: Int diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index f2825afca2..ab08baf291 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -144,7 +144,7 @@ final class NukeDataModal: Modal { title: "clearDataAll".localized(), body: .attributedText( { - switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { + switch dependencies[singleton: .sessionProManager].currentUserCurrentProStatus { case .active: "proClearAllDataNetwork" .put(key: "app_pro", value: Constants.app_pro) @@ -169,7 +169,7 @@ final class NukeDataModal: Modal { } private func clearDeviceOnly() { - switch dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus { + switch dependencies[singleton: .sessionProManager].currentUserCurrentProStatus { case .active: let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 13baf05106..5f88642134 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -139,6 +139,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let proAutoRenewing: Bool? let proAccessExpiryTimestampMs: UInt64? let proLatestPaymentItem: Network.SessionPro.PaymentItem? + let proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform + let proIsRefunding: SessionPro.IsRefunding @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] { SessionProSettingsViewModel.sections( @@ -162,10 +164,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .currentUserProAutoRenewing(sessionProManager), .currentUserProAccessExpiryTimestampMs(sessionProManager), .currentUserProLatestPaymentItem(sessionProManager), + .currentUserLatestPaymentOriginatingPlatform(sessionProManager), + .currentUserProIsRefunding(sessionProManager), .setting(.groupsUpgradedCounter), .setting(.proBadgesSentCounter), - .setting(.longerMessagesSentCounter), - .feature(.mockCurrentUserSessionProLoadingState) + .setting(.longerMessagesSentCounter) ] } @@ -181,7 +184,9 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proStatus: nil, proAutoRenewing: nil, proAccessExpiryTimestampMs: nil, - proLatestPaymentItem: nil + proLatestPaymentItem: nil, + proLastPaymentOriginatingPlatform: .iOS, + proIsRefunding: false ) } } @@ -203,6 +208,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType var proAutoRenewing: Bool? = previousState.proAutoRenewing var proAccessExpiryTimestampMs: UInt64? = previousState.proAccessExpiryTimestampMs var proLatestPaymentItem: Network.SessionPro.PaymentItem? = previousState.proLatestPaymentItem + var proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform = previousState.proLastPaymentOriginatingPlatform + var proIsRefunding: SessionPro.IsRefunding = previousState.proIsRefunding /// Store a local copy of the events so we can manipulate it based on the state changes let eventsToProcess: [ObservedEvent] = events @@ -212,7 +219,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType do { loadingState = await dependencies[singleton: .sessionProManager].loadingState .first(defaultValue: .loading) - proStatus = await dependencies[singleton: .sessionProManager].backendUserProStatus + proStatus = await dependencies[singleton: .sessionProManager].proStatus .first(defaultValue: nil) proAutoRenewing = await dependencies[singleton: .sessionProManager].autoRenewing .first(defaultValue: nil) @@ -220,6 +227,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .first(defaultValue: nil) proLatestPaymentItem = await dependencies[singleton: .sessionProManager].latestPaymentItem .first(defaultValue: nil) + proLastPaymentOriginatingPlatform = await dependencies[singleton: .sessionProManager].latestPaymentOriginatingPlatform + .first(defaultValue: .iOS) + proIsRefunding = await dependencies[singleton: .sessionProManager].isRefunding + .first(defaultValue: false) try await dependencies[singleton: .storage].readAsync { db in numberOfGroupsUpgraded = (db[.groupsUpgradedCounter] ?? 0) @@ -246,16 +257,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) /// Process any general event changes - switch (dependencies[feature: .mockCurrentUserSessionProLoadingState], changes.latest(.currentUserProLoadingState, as: SessionPro.LoadingState.self)) { - case (.simulate(let mockedState), _): loadingState = mockedState - case (.useActual, .some(let updatedValue)): loadingState = updatedValue - default: break + if let value = changes.latest(.currentUserProLoadingState, as: SessionPro.LoadingState.self) { + loadingState = value } - switch (dependencies[feature: .mockCurrentUserSessionProBackendStatus], changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self)) { - case (.simulate(let mockedState), _): proStatus = mockedState - case (.useActual, .some(let updatedValue)): proStatus = updatedValue - default: break + if let value = changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self) { + proStatus = value } if let value = changes.latest(.currentUserProAutoRenewing, as: Bool.self) { @@ -270,6 +277,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proLatestPaymentItem = value } + if let value = changes.latest(.currentUserLatestPaymentOriginatingPlatform, as: SessionProUI.ClientPlatform.self) { + proLastPaymentOriginatingPlatform = value + } + + if let value = changes.latest(.currentUserProIsRefunding, as: SessionPro.IsRefunding.self) { + proIsRefunding = value + } + changes.forEach(.profile, as: ProfileEvent.self) { event in switch event.change { case .name(let name): profile = profile.with(name: name) @@ -326,7 +341,9 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proStatus: proStatus, proAutoRenewing: proAutoRenewing, proAccessExpiryTimestampMs: proAccessExpiryTimestampMs, - proLatestPaymentItem: proLatestPaymentItem + proLatestPaymentItem: proLatestPaymentItem, + proLastPaymentOriginatingPlatform: proLastPaymentOriginatingPlatform, + proIsRefunding: proIsRefunding ) } @@ -405,7 +422,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType from: .logoWithPro, title: { switch state.proStatus { - case .active, .neverBeenPro, .none://.refunding, .none: // TODO: [PRO] Add in "refunding" status + case .active, .neverBeenPro, .none: "proStatusLoading" .put(key: "pro", value: Constants.pro) .localized() @@ -418,7 +435,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType }(), description: { switch state.proStatus { - case .active, .neverBeenPro, .none://.refunding, .none: // TODO: [PRO] Add in "refunding" status + case .active, .neverBeenPro, .none: "proStatusLoadingDescription" .put(key: "pro", value: Constants.pro) .localized() @@ -608,7 +625,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_roadmap) } + onTap: { [weak viewModel] in viewModel?.openUrl(SessionPro.Metadata.urls.roadmap) } ) ) ) @@ -677,15 +694,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_support_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(SessionPro.Metadata.urls.support) } ) ] ) - return switch state.proStatus { - case .none, .neverBeenPro: [ logo, proFeatures, help ] - case .active: [ logo, proStats, proSettings, proFeatures, proManagement, help ] - case .expired: [ logo, proManagement, proFeatures, help ] + return switch (state.proStatus, state.proIsRefunding) { + case (.none, _), (.neverBeenPro, _): [ logo, proFeatures, help ] + case (.active, .notRefunding): [ logo, proStats, proSettings, proFeatures, proManagement, help ] + case (.expired, _): [ logo, proManagement, proFeatures, help ] + case (.active, .refunding): [ logo, proStats, proSettings, proFeatures, help ] } } @@ -696,100 +714,152 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType previousState: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - return [ - { - switch state.proStatus { - case .none, .neverBeenPro, .expired: nil - case .active: - SessionListScreenContent.ListItemInfo( - id: .updatePlan, - variant: .cell( - info: ListItemCell.Info( - title: SessionListScreenContent.TextInfo( - "updateAccess" - .put(key: "pro", value: Constants.pro) - .localized(), - font: .Headings.H8 - ), - description: { - switch state.loadingState { - case .loading: - return SessionListScreenContent.TextInfo( - font: .Body.smallRegular, - attributedString: "proAccessLoadingEllipsis" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.smallRegular) - ) - - case .error: - return SessionListScreenContent.TextInfo( - font: .Body.smallRegular, - attributedString: "errorLoadingProAccess" - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.smallRegular), - color: .warning + let initialProSettingsElements: [SessionListScreenContent.ListItemInfo] + + switch (state.proStatus, state.proIsRefunding) { + case (.none, _), (.neverBeenPro, _), (.expired, _): initialProSettingsElements = [] + case (.active, .notRefunding): + initialProSettingsElements = [ + SessionListScreenContent.ListItemInfo( + id: .updatePlan, + variant: .cell( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + font: .Headings.H8 + ), + description: { + switch state.loadingState { + case .loading: + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "proAccessLoadingEllipsis" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.smallRegular) + ) + + case .error: + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "errorLoadingProAccess" + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.smallRegular), + color: .warning + ) + + case .success: + let expirationDate: Date = Date( + timeIntervalSince1970: floor(Double(state.proAccessExpiryTimestampMs ?? 0) / 1000) + ) + let expirationString: String = expirationDate + .timeIntervalSince(viewModel.dependencies.dateNow) + .ceilingFormatted( + format: .long, + allowedUnits: [.day, .hour, .minute] ) - case .success: - let expirationDate: Date = Date( - timeIntervalSince1970: floor(Double(state.proAccessExpiryTimestampMs ?? 0) / 1000) - ) - let expirationString: String = expirationDate - .timeIntervalSince(viewModel.dependencies.dateNow) - .ceilingFormatted( - format: .long, - allowedUnits: [.day, .hour, .minute] - ) - - return SessionListScreenContent.TextInfo( - font: .Body.smallRegular, - attributedString: ( - state.proAutoRenewing == true ? - "proAutoRenewTime" - .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expirationString) - .localizedFormatted(Fonts.Body.smallRegular) : - "proExpiringTime" - .put(key: "pro", value: Constants.pro) - .put(key: "time", value: expirationString) - .localizedFormatted(Fonts.Body.smallRegular) - ) + return SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: ( + state.proAutoRenewing == true ? + "proAutoRenewTime" + .put(key: "pro", value: Constants.pro) + .put(key: "time", value: expirationString) + .localizedFormatted(Fonts.Body.smallRegular) : + "proExpiringTime" + .put(key: "pro", value: Constants.pro) + .put(key: "time", value: expirationString) + .localizedFormatted(Fonts.Body.smallRegular) ) - } - }(), - trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) - ) - ), - onTap: { [weak viewModel] in - switch state.loadingState { - case .success: viewModel?.updateProPlan(state: state) - case .loading: - viewModel?.showLoadingModal( - from: .updatePlan, - title: "proAccessLoading" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessLoadingDescription" - .put(key: "pro", value: Constants.pro) - .localized() - ) - - case .error: - viewModel?.showErrorModal( - from: .updatePlan, - title: "proAccessError" - .put(key: "pro", value: Constants.pro) - .localized(), - description: "proAccessNetworkLoadError" - .put(key: "pro", value: Constants.pro) - .put(key: "app_name", value: Constants.app_name) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ) - } + ) + } + }(), + trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) + ) + ), + onTap: { [weak viewModel] in + switch state.loadingState { + case .success: viewModel?.updateProPlan(state: state) + case .loading: + viewModel?.showLoadingModal( + from: .updatePlan, + title: "proAccessLoading" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessLoadingDescription" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case .error: + viewModel?.showErrorModal( + from: .updatePlan, + title: "proAccessError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessNetworkLoadError" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + ) } - ) - } - }(), + } + ) + ] + + case (.active, .refunding): + initialProSettingsElements = [ + SessionListScreenContent.ListItemInfo( + id: .refundRequested, + variant: .cell( + info: ListItemCell.Info( + title: SessionListScreenContent.TextInfo( + "proRequestedRefund".localized(), + font: .Headings.H8 + ), + description: SessionListScreenContent.TextInfo( + font: .Body.smallRegular, + attributedString: "processingRefundRequest" + .put(key: "platform", value: state.proLastPaymentOriginatingPlatform.platform) + .localizedFormatted(Fonts.Body.smallRegular) + ), + trailingAccessory: .icon(.circleAlert, size: .large) + ) + ), + onTap: { [weak viewModel] in + switch state.loadingState { + case .success: viewModel?.updateProPlan(state: state) + case .loading: + viewModel?.showLoadingModal( + from: .updatePlan, + title: "proAccessLoading" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessLoadingDescription" + .put(key: "pro", value: Constants.pro) + .localized() + ) + + case .error: + viewModel?.showErrorModal( + from: .updatePlan, + title: "proAccessError" + .put(key: "pro", value: Constants.pro) + .localized(), + description: "proAccessNetworkLoadError" + .put(key: "pro", value: Constants.pro) + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + ) + } + } + ) + ] + } + + return initialProSettingsElements + [ SessionListScreenContent.ListItemInfo( id: .proBadge, variant: .cell( @@ -824,7 +894,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } } ) - ].compactMap { $0 } + ] } // MARK: - Pro Management Elements @@ -833,11 +903,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - return switch state.proStatus { - case .none, .neverBeenPro: [] - case .active: - [ - state.proAutoRenewing != true ? nil : + switch (state.proStatus, state.proIsRefunding) { + case (.none, _), (.neverBeenPro, _), (.active, .refunding): return [] + case (.active, .notRefunding): + var renewingItems: [SessionListScreenContent.ListItemInfo] = [] + + if state.proAutoRenewing == true { + renewingItems.append( SessionListScreenContent.ListItemInfo( id: .cancelPlan, variant: .cell( @@ -853,7 +925,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ), onTap: { [weak viewModel] in viewModel?.cancelPlan(state: state) } - ), + ) + ) + } + + return renewingItems + [ SessionListScreenContent.ListItemInfo( id: .requestRefund, variant: .cell( @@ -868,10 +944,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ), onTap: { [weak viewModel] in viewModel?.requestRefund(state: state) } ) - ].compactMap { $0 } + ] - case .expired: - [ + case (.expired, _): + return [ SessionListScreenContent.ListItemInfo( id: .renewPlan, variant: .cell( @@ -1040,9 +1116,7 @@ extension SessionProSettingsViewModel { try? await dependencies[singleton: .sessionProManager].refreshProState() } }, - onCancel: { [weak self] _ in - self?.openUrl(Constants.session_pro_support_url) - } + onCancel: { [weak self] _ in self?.openUrl(SessionPro.Metadata.urls.support) } ) ) @@ -1060,7 +1134,9 @@ extension SessionProSettingsViewModel { proStatus: state.proStatus, autoRenewing: state.proAutoRenewing, accessExpiryTimestampMs: state.proAccessExpiryTimestampMs, - latestPaymentItem: state.proLatestPaymentItem + latestPaymentItem: state.proLatestPaymentItem, + lastPaymentOriginatingPlatform: state.proLastPaymentOriginatingPlatform, + isRefunding: state.proIsRefunding ), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ) @@ -1071,6 +1147,66 @@ extension SessionProSettingsViewModel { } @MainActor func recoverProPlan() { + Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .sessionProManager]] in + try? await manager.refreshProState() + + let status: Network.SessionPro.BackendUserProStatus = (await manager.proStatus + .first(defaultValue: nil) ?? .neverBeenPro) + + await MainActor.run { [weak self] in + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: { + switch status { + case .active: + return "proAccessRestored" + .put(key: "pro", value: Constants.pro) + .localized() + + case .neverBeenPro, .expired: + return "proAccessNotFound" + .put(key: "pro", value: Constants.pro) + .localized() + } + }(), + body: { + switch status { + case .active: + return .text( + "proAccessRestoredDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "pro", value: Constants.pro) + .localized(), + scrollMode: .never + ) + + case .neverBeenPro, .expired: + return .text( + "proAccessNotFoundDescription" + .put(key: "app_name", value: Constants.app_name) + .put(key: "pro", value: Constants.pro) + .localized(), + scrollMode: .never + ) + } + }(), + confirmTitle: (status == .active ? nil : "helpSupport".localized()), + cancelTitle: (status == .active ? "okay".localized() : "close".localized()), + cancelStyle: (status == .active ? .textPrimary : .danger), + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + guard status != .active else { + return modal.dismiss(animated: true) + } + + self?.openUrl(SessionPro.Metadata.urls.proAccessNotFound) + } + ) + ) + + self?.transitionToScreen(modal, transitionType: .present) + } + } } func cancelPlan(state: State) { @@ -1079,15 +1215,7 @@ extension SessionProSettingsViewModel { viewModel: SessionProPaymentScreenContent.ViewModel( dependencies: dependencies, dataModel: SessionProPaymentScreenContent.DataModel( - flow: .cancel( - originatingPlatform: { - switch state.proLatestPaymentItem?.paymentProvider { - case .none: return .iOS /// Should default to iOS on iOS devices - case .appStore: return .iOS - case .playStore: return .android - } - }() - ), + flow: .cancel(originatingPlatform: state.proLastPaymentOriginatingPlatform), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ) ) @@ -1103,13 +1231,7 @@ extension SessionProSettingsViewModel { dependencies: dependencies, dataModel: SessionProPaymentScreenContent.DataModel( flow: .refund( - originatingPlatform: { - switch state.proLatestPaymentItem?.paymentProvider { - case .none: return .iOS /// Should default to iOS on iOS devices - case .appStore: return .iOS - case .playStore: return .android - } - }(), + originatingPlatform: state.proLastPaymentOriginatingPlatform, requestedAt: nil ), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } @@ -1206,36 +1328,30 @@ extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { proStatus: Network.SessionPro.BackendUserProStatus?, autoRenewing: Bool?, accessExpiryTimestampMs: UInt64?, - latestPaymentItem: Network.SessionPro.PaymentItem? + latestPaymentItem: Network.SessionPro.PaymentItem?, + lastPaymentOriginatingPlatform: SessionProUI.ClientPlatform, + isRefunding: SessionPro.IsRefunding ) { let latestPlan: SessionPro.Plan? = plans.first { $0.variant == latestPaymentItem?.plan } let expiryDate: Date? = accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } - switch (proStatus, latestPlan) { - case (.none, _), (.neverBeenPro, _), (.active, .none): self = .purchase - case (.active, .some(let plan)): + switch (proStatus, latestPlan, isRefunding) { + case (.none, _, _), (.neverBeenPro, _, _), (.active, .none, _): self = .purchase + case (.active, .some(let plan), .notRefunding): self = .update( currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), expiredOn: (expiryDate ?? Date.distantPast), isAutoRenewing: (autoRenewing == true), - originatingPlatform: { - switch latestPaymentItem?.paymentProvider { - case .none: return .iOS /// Should default to iOS on iOS devices - case .appStore: return .iOS - case .playStore: return .android - } - }() + originatingPlatform: lastPaymentOriginatingPlatform ) - case (.expired, _): - self = .renew( - originatingPlatform: { - switch latestPaymentItem?.paymentProvider { - case .none: return .iOS /// Should default to iOS on iOS devices - case .appStore: return .iOS - case .playStore: return .android - } - }() + case (.expired, _, _): self = .renew(originatingPlatform: lastPaymentOriginatingPlatform) + case (.active, .some, .refunding): + self = .refund( + originatingPlatform: lastPaymentOriginatingPlatform, + requestedAt: (latestPaymentItem?.refundRequestedTimestampMs).map { + Date(timeIntervalSince1970: (Double($0) / 1000)) + } ) } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 0858053e64..9aa857f392 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -38,7 +38,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self.dependencies = dependencies self.internalState = State.initialState( userSessionId: dependencies[cache: .general].sessionId, - sessionProBackendStatus: dependencies[singleton: .sessionProManager].currentUserCurrentBackendProStatus + proStatus: dependencies[singleton: .sessionProManager].currentUserCurrentProStatus ) bindState() @@ -157,7 +157,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile - let sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? + let proStatus: Network.SessionPro.BackendUserProStatus? let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -175,7 +175,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return [ .profile(userSessionId.hexString), - .feature(.mockCurrentUserSessionProBackendStatus), .feature(.serviceNetwork), .feature(.forceOffline), .setting(.developerModeEnabled), @@ -187,12 +186,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl static func initialState( userSessionId: SessionId, - sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? + proStatus: Network.SessionPro.BackendUserProStatus? ) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), - sessionProBackendStatus: sessionProBackendStatus, + proStatus: proStatus, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -226,7 +225,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile - var sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? = previousState.sessionProBackendStatus + var proStatus: Network.SessionPro.BackendUserProStatus? = previousState.proStatus var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -235,8 +234,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isInitialFetch { serviceNetwork = dependencies[feature: .serviceNetwork] forceOffline = dependencies[feature: .forceOffline] - sessionProBackendStatus = await dependencies[singleton: .sessionProManager].backendUserProStatus - .first(defaultValue: nil) + proStatus = await dependencies[singleton: .sessionProManager].proStatus.first(defaultValue: nil) dependencies.mutate(cache: .libSession) { libSession in profile = libSession.profile @@ -248,13 +246,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl /// Split the events let changes: EventChangeset = events.split() - /// If the device has a mock pro status set then use that - switch (dependencies[feature: .mockCurrentUserSessionProBackendStatus], changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self)) { - case (.simulate(let mockedState), _): sessionProBackendStatus = mockedState - case (.useActual, .some(let updatedValue)): sessionProBackendStatus = updatedValue - default: break - } - /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading /// it then then there will be a diff in the `State` and the UI will update if @@ -301,11 +292,15 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + if let value = changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self) { + proStatus = value + } + /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, - sessionProBackendStatus: sessionProBackendStatus, + proStatus: proStatus, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -347,7 +342,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onTap: { [weak viewModel] in viewModel?.updateProfilePicture( currentUrl: state.profile.displayPictureUrl, - sessionProBackendStatus: state.sessionProBackendStatus + proStatus: state.proStatus ) } ), @@ -358,7 +353,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, trailingImage: { - switch state.sessionProBackendStatus { + switch state.proStatus { case .none, .neverBeenPro: return nil case .active: return ( @@ -461,13 +456,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl id: .sessionPro, leadingAccessory: .proBadge(size: .small), title: { - switch state.sessionProBackendStatus { + switch state.proStatus { case .none, .neverBeenPro: return "upgradeSession" .put(key: "app_name", value: Constants.app_name) .localized() - case .active, .refunding: + case .active: return "sessionProBeta" .put(key: "app_pro", value: Constants.app_pro) .localized() @@ -766,7 +761,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture( currentUrl: String?, - sessionProBackendStatus: Network.SessionPro.BackendUserProStatus? + proStatus: Network.SessionPro.BackendUserProStatus? ) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false @@ -793,7 +788,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { - switch (dependencies[feature: .sessionProEnabled], sessionProBackendStatus) { + switch (dependencies[feature: .sessionProEnabled], proStatus) { case (false, _): return nil case (true, .active): return "proAnimatedDisplayPictureModalDescription" @@ -825,7 +820,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dataManager: dependencies[singleton: .imageDataManager], onProBageTapped: { [weak self, dependencies] in dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (sessionProBackendStatus == .active)), + .animatedProfileImage(isSessionProActivated: (proStatus == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -874,7 +869,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isAnimatedImage && !dependencies[feature: .sessionProEnabled] { didShowCTAModal = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (sessionProBackendStatus == .active)), + .animatedProfileImage(isSessionProActivated: (proStatus == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 3b9d35c89e..6a24fecf6a 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -176,7 +176,6 @@ public extension Profile { { dataMessageProto.setProfileKey(displayPictureEncryptionKey) profileProto.setProfilePicture(displayPictureUrl) - // TODO: Add ProProof if needed } if let profileLastUpdated: TimeInterval = profileLastUpdated { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 472f16b484..53ee042c0c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -75,20 +75,13 @@ internal extension LibSessionCacheType { }(), nicknameUpdate: .set(to: data.profile.nickname), proUpdate: { - guard let proof: Network.SessionPro.ProProof = Network.SessionPro.ProProof(profile: data.profile) else { - return .none - } - - let isActive: Bool = dependencies[singleton: .sessionProManager].proProofIsActive( - for: proof, - atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) + guard let genIndexHashHex: String = profile.proGenIndexHashHex else { return .none } return .contactUpdate( - SessionPro.DecodedProForMessage( - status: (isActive ? .valid : .expired), - proProof: proof, - features: data.profile.proFeatures + Profile.ProState( + features: data.profile.proFeatures, + expiryUnixTimestampMs: data.profile.proExpiryUnixTimestampMs, + genIndexHashHex: genIndexHashHex ) ) }(), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index a5aadd2b3d..97960c5b9e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -70,17 +70,12 @@ internal extension LibSessionCacheType { else { return .none } let features: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) - let status: SessionPro.DecodedStatus = dependencies[singleton: .sessionProManager].proStatus( - for: proConfig.proProof, - verifyPubkey: rotatingKeyPair.publicKey, - atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) return .currentUserUpdate( - SessionPro.DecodedProForMessage( - status: status, - proProof: proConfig.proProof, - features: features + Profile.ProState( + features: features, + expiryUnixTimestampMs: proConfig.proProof.expiryUnixTimestampMs, + genIndexHashHex: proConfig.proProof.genIndexHash.toHexString() ) ) }(), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 58a0062ef4..8fbba8edc1 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -159,7 +159,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - proUpdate: .contactUpdate(decodedMessage.decodedPro), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, currentUserSessionIds: currentUserSessionIds, using: dependencies @@ -261,7 +261,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - proUpdate: .contactUpdate(decodedMessage.decodedPro), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, currentUserSessionIds: currentUserSessionIds, using: dependencies @@ -616,7 +616,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - proUpdate: .contactUpdate(decodedMessage.decodedPro), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, currentUserSessionIds: currentUserSessionIds, using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index ade3ae348e..4f57461383 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -30,7 +30,7 @@ extension MessageReceiver { publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .contactUpdateTo(profile, fallback: .none), - proUpdate: .contactUpdate(decodedMessage.decodedPro), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, currentUserSessionIds: currentUserSessionIds, using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 36b97d1df6..405a46810f 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -36,7 +36,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .contactUpdateTo(profile, fallback: .contactRemove), blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), - proUpdate: .contactUpdate(decodedMessage.decodedPro), + proUpdate: .contactUpdate(Profile.ProState(decodedMessage.decodedPro)), profileUpdateTimestamp: profile.updateTimestampSeconds, currentUserSessionIds: currentUserSessionIds, using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 3816689b04..ce12ed17c8 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -82,11 +82,16 @@ public enum MessageReceiver { message.sigTimestampMs = (proto.hasSigTimestamp ? proto.sigTimestamp : nil) message.receivedTimestampMs = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - /// If the `decodedPro` content on the message is not valid then we should remove any pro content from the message itself - /// as it's invalid - if decodedMessage.decodedPro?.status != .valid { - message.proFeatures = nil - message.proProof = nil + /// If the `decodedPro` content on the message is not `valid` or `expired` then we should remove any pro content from + /// the message itself as it's invalid + /// + /// **Note:** We sync the `expired` case because it's possible another device received and synced it while the data was + /// `valid` and we don't want to incorrectly remove pro state (or cause a config ping-pong due to inconsistent behaviours) + switch decodedMessage.decodedPro?.status { + case .valid, .expired: break + case .none, .invalidProBackendSig, .invalidUserSig: + message.proFeatures = nil + message.proProof = nil } /// Perform validation and assign additional message values based on the origin diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 7186e5d4cd..24b7673df2 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -1,6 +1,7 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation +import StoreKit import SessionUtil import SessionUIKit import SessionNetworkingKit @@ -29,42 +30,45 @@ public actor SessionProManager: SessionProManagerType { private let dependencies: Dependencies nonisolated private let syncState: SessionProManagerSyncState private var isRefreshingState: Bool = false - private var proStatusObservationTask: Task? + private var proMockingObservationTask: Task? private var rotatingKeyPair: KeyPair? public var plans: [SessionPro.Plan] = [] - public var proFeatures: SessionPro.Features = .none nonisolated private let loadingStateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.loading) - nonisolated private let backendUserProStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let proProofStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let proStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let autoRenewingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let accessExpiryTimestampMsStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let latestPaymentItemStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) + nonisolated private let latestPaymentOriginatingPlatformStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.iOS) + nonisolated private let isRefundingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(false) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } - nonisolated public var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { - syncState.backendUserProStatus + nonisolated public var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { + syncState.proStatus } - nonisolated public var pinnedConversationLimit: Int { SessionPro.PinnedConversationLimit } - nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.backendUserProStatus == .active } nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } + nonisolated public var currentUserCurrentProFeatures: SessionPro.Features? { syncState.proFeatures } + nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.proStatus == .active } + + nonisolated public var pinnedConversationLimit: Int { SessionPro.PinnedConversationLimit } + nonisolated public var characterLimit: Int { + (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) + } nonisolated public var currentUserIsPro: AsyncStream { - backendUserProStatusStream.stream + proStatusStream.stream .map { $0 == .active } .asAsyncStream() } - nonisolated public var characterLimit: Int { - (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) - } nonisolated public var loadingState: AsyncStream { loadingStateStream.stream } - nonisolated public var backendUserProStatus: AsyncStream { - backendUserProStatusStream.stream - } - nonisolated public var proProof: AsyncStream { proProofStream.stream } + nonisolated public var proStatus: AsyncStream { proStatusStream.stream } nonisolated public var autoRenewing: AsyncStream { autoRenewingStream.stream } nonisolated public var accessExpiryTimestampMs: AsyncStream { accessExpiryTimestampMsStream.stream } nonisolated public var latestPaymentItem: AsyncStream { latestPaymentItemStream.stream } + nonisolated public var latestPaymentOriginatingPlatform: AsyncStream { + latestPaymentOriginatingPlatformStream.stream + } + nonisolated public var isRefunding: AsyncStream { isRefundingStream.stream } // MARK: - Initialization @@ -84,7 +88,7 @@ public actor SessionProManager: SessionProManagerType { } deinit { - proStatusObservationTask?.cancel() + proMockingObservationTask?.cancel() } // MARK: - Functions @@ -223,16 +227,23 @@ public actor SessionProManager: SessionProManagerType { }() syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - backendUserProStatus: .set(to: proStatus), + proStatus: .set(to: mockedIfNeeded(proStatus)), proProof: .set(to: proState.proConfig?.proProof), proFeatures: .set(to: proState.profile.proFeatures) ) /// Then update the async state and streams + let oldAccessExpiryTimestampMs: UInt64? = await self.accessExpiryTimestampMsStream.getCurrent() self.rotatingKeyPair = rotatingKeyPair - self.proFeatures = proState.profile.proFeatures - await self.proProofStream.send(proState.proConfig?.proProof) - await self.backendUserProStatusStream.send(proStatus) + await self.proStatusStream.send(mockedIfNeeded(proStatus)) + await self.accessExpiryTimestampMsStream.send(proState.accessExpiryTimestampMs) + await self.sendUpdatedIsRefundingState() + + /// If the `accessExpiryTimestampMs` value changed then we should trigger a refresh because it generally means that + /// other device did something that should refresh the pro state + if proState.accessExpiryTimestampMs != oldAccessExpiryTimestampMs { + try? await refreshProState() + } } @discardableResult @MainActor public func showSessionProCTAIfNeeded( @@ -246,7 +257,7 @@ public actor SessionProManager: SessionProManagerType { switch variant { case .groupLimit: break /// The `groupLimit` CTA can be shown for Session Pro users as well default: - guard syncState.backendUserProStatus != .active else { return false } + guard syncState.proStatus != .active else { return false } break } @@ -268,7 +279,8 @@ public actor SessionProManager: SessionProManagerType { public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { // TODO: [PRO] Need to actually implement this dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .simulate(.active)) - await backendUserProStatusStream.send(.active) + await proStatusStream.send(.active) + await sendUpdatedIsRefundingState() completion?(true) } @@ -283,7 +295,7 @@ public actor SessionProManager: SessionProManagerType { /// Only reset the `loadingState` if it's currently in an error state if await loadingStateStream.getCurrent() == .error { - await loadingStateStream.send(.loading) + await loadingStateStream.send(mockedIfNeeded(.loading)) } /// Get the product list from the AppStore first (need this to populate the UI) @@ -305,15 +317,19 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") Log.error(.sessionPro, "Failed to retrieve pro details due to error(s): \(errorString)") - await loadingStateStream.send(.error) + await loadingStateStream.send(mockedIfNeeded(.error)) throw NetworkError.explicit(errorString) } - syncState.update(backendUserProStatus: .set(to: response.status)) - await self.backendUserProStatusStream.send(response.status) + syncState.update(proStatus: .set(to: mockedIfNeeded(response.status))) + await self.proStatusStream.send(mockedIfNeeded(response.status)) await self.autoRenewingStream.send(response.autoRenewing) await self.accessExpiryTimestampMsStream.send(response.expiryTimestampMs) await self.latestPaymentItemStream.send(response.items.first) + await self.latestPaymentOriginatingPlatformStream.send(mockedIfNeeded( + SessionProUI.ClientPlatform(response.items.first?.paymentProvider) + )) + await self.sendUpdatedIsRefundingState() switch response.status { case .active: @@ -327,7 +343,7 @@ public actor SessionProManager: SessionProManagerType { case .expired: try await clearProProof() } - await loadingStateStream.send(.success) + await loadingStateStream.send(mockedIfNeeded(.success)) } public func refreshProProofIfNeeded( @@ -337,8 +353,8 @@ public actor SessionProManager: SessionProManagerType { ) async throws { guard status == .active else { return } - let needsNewProof: Bool = await { - guard let currentProof: Network.SessionPro.ProProof = await proProofStream.getCurrent() else { + let needsNewProof: Bool = { + guard let currentProof: Network.SessionPro.ProProof = syncState.proProof else { return true } @@ -402,12 +418,12 @@ public actor SessionProManager: SessionProManagerType { let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - backendUserProStatus: .set(to: proStatus), + proStatus: .set(to: mockedIfNeeded(proStatus)), proProof: .set(to: response.proof) ) self.rotatingKeyPair = rotatingKeyPair - await self.proProofStream.send(response.proof) - await self.backendUserProStatusStream.send(proStatus) + await self.proStatusStream.send(mockedIfNeeded(proStatus)) + await self.sendUpdatedIsRefundingState() } public func addProPayment(transactionId: String) async throws { @@ -459,20 +475,108 @@ public actor SessionProManager: SessionProManagerType { let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - backendUserProStatus: .set(to: proStatus), + proStatus: .set(to: mockedIfNeeded(proStatus)), proProof: .set(to: response.proof) ) self.rotatingKeyPair = rotatingKeyPair - await self.proProofStream.send(response.proof) - await self.backendUserProStatusStream.send(proStatus) + await self.proStatusStream.send(mockedIfNeeded(proStatus)) + await self.sendUpdatedIsRefundingState() /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other /// edge-cases since it's the main driver to the Pro state) try? await refreshProState() } + + public func requestRefund( + scene: UIWindowScene + ) async throws { + guard let latestPaymentItem: Network.SessionPro.PaymentItem = await latestPaymentItemStream.getCurrent() else { + throw NetworkError.explicit("No latest payment item") + } + + /// User has already requested a refund for this item + guard latestPaymentItem.refundRequestedTimestampMs == 0 else { + throw NetworkError.explicit("Refund already requested for latest payment") + } + + /// Only Apple support refunding via this mechanism so no point continuing if we don't have a `appleTransactionId` + guard let transactionId: String = latestPaymentItem.appleTransactionId else { + throw NetworkError.explicit("Latest payment wasn't originated from an Apple device") + } + + /// If we don't have the `fakeAppleSubscriptionForDev` feature enabled then we need to actually request the refund from Apple + if !dependencies[feature: .fakeAppleSubscriptionForDev] { + var transactions: [Transaction] = [] + + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result { + transactions.append(transaction) + } + } + + let sortedTransactions: [Transaction] = transactions.sorted { $0.purchaseDate > $1.purchaseDate } + let latestTransaction: Transaction? = sortedTransactions.first + let latestPaymentItemTransaction: Transaction? = sortedTransactions.first(where: { "\($0.id)" == latestPaymentItem.appleTransactionId }) + + if latestTransaction != latestPaymentItemTransaction { + Log.warn(.sessionPro, "The latest transaction didn't match the latest payment item") + } + + /// Prioritise the transaction that matches the latest payment item + guard let targetTransaction: Transaction = (latestPaymentItemTransaction ?? latestTransaction) else { + throw NetworkError.explicit("No Transaction") + } + + let status: Transaction.RefundRequestStatus = try await targetTransaction.beginRefundRequest(in: scene) + + switch status { + case .success: break /// Continue on to send the refund to our backend + case .userCancelled: throw NetworkError.explicit("Cancelled refund request") + @unknown default: throw NetworkError.explicit("Unknown refund request status") + } + } + + let refundRequestedTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let request = try Network.SessionPro.setPaymentRefundRequested( + transactionId: transactionId, + refundRequestedTimestampMs: refundRequestedTimestampMs, + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + using: dependencies + ) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.SetPaymentRefundRequestedResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + Log.error(.sessionPro, "Refund submission failed due to error(s): \(errorString)") + throw NetworkError.explicit(errorString) + } + + /// Need to refresh the pro state to get the updated payment item (which should now include a `refundRequestedTimestampMs`) + try await refreshProState() + } // MARK: - Internal Functions + /// The user is in a refunding state when their pro status is `active` and the `refundRequestedTimestampMs` is not `0` + private func sendUpdatedIsRefundingState() async { + let status: Network.SessionPro.BackendUserProStatus? = await proStatusStream.getCurrent() + let paymentItem: Network.SessionPro.PaymentItem? = await latestPaymentItemStream.getCurrent() + + await isRefundingStream.send( + mockedIfNeeded( + SessionPro.IsRefunding( + status == .active && + (paymentItem?.refundRequestedTimestampMs ?? 0) > 0 + ) + ) + ) + } + private func clearProProof() async throws { try await dependencies[singleton: .storage].writeAsync { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in @@ -494,54 +598,6 @@ public actor SessionProManager: SessionProManagerType { // TODO: [PRO] Need to add these in (likely part of pro settings) } - - private func startProStatusObservations() { - proStatusObservationTask?.cancel() - proStatusObservationTask = Task { - await withTaskGroup(of: Void.self) { [weak self, dependencies] group in - if #available(iOS 16, *) { - /// Observe the main Session Pro feature flag - group.addTask { - for await proEnabled in dependencies.stream(feature: .sessionProEnabled) { - guard proEnabled else { - self?.syncState.update(backendUserProStatus: .set(to: nil)) - await self?.backendUserProStatusStream.send(.none) - continue - } - - /// Restart the observation (will fetch the correct current states) - try? await self?.refreshProState() - } - } - - /// Observe the explicit mocking for the current session pro status - group.addTask { - for await status in dependencies.stream(feature: .mockCurrentUserSessionProBackendStatus) { - /// Ignore status updates if pro is enabled, and if the mock status was removed we need to fetch - /// the "real" status - guard dependencies[feature: .sessionProEnabled] else { continue } - - switch status { - case .useActual: try? await self?.refreshProState() - case .simulate(let status): - self?.syncState.update(backendUserProStatus: .set(to: status)) - await self?.backendUserProStatusStream.send(status) - } - } - } - } - - /// If Session Pro isn't enabled then no need to do any of the other tasks (they check the proper Session Pro stauts - /// via `libSession` and the network - guard dependencies[feature: .sessionProEnabled] else { - await group.waitForAll() - return - } - - await group.waitForAll() - } - } - } } // MARK: - SyncState @@ -550,15 +606,13 @@ private final class SessionProManagerSyncState { private let lock: NSLock = NSLock() private let _dependencies: Dependencies private var _rotatingKeyPair: KeyPair? = nil - private var _backendUserProStatus: Network.SessionPro.BackendUserProStatus? = nil + private var _proStatus: Network.SessionPro.BackendUserProStatus? = nil private var _proProof: Network.SessionPro.ProProof? = nil private var _proFeatures: SessionPro.Features = .none fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } - fileprivate var backendUserProStatus: Network.SessionPro.BackendUserProStatus? { - lock.withLock { _backendUserProStatus } - } + fileprivate var proStatus: Network.SessionPro.BackendUserProStatus? { lock.withLock { _proStatus } } fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } fileprivate var proFeatures: SessionPro.Features? { lock.withLock { _proFeatures } } @@ -568,13 +622,13 @@ private final class SessionProManagerSyncState { fileprivate func update( rotatingKeyPair: Update = .useExisting, - backendUserProStatus: Update = .useExisting, + proStatus: Update = .useExisting, proProof: Update = .useExisting, proFeatures: Update = .useExisting ) { lock.withLock { self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) - self._backendUserProStatus = backendUserProStatus.or(self._backendUserProStatus) + self._proStatus = proStatus.or(self._proStatus) self._proProof = proProof.or(self._proProof) self._proFeatures = proFeatures.or(self._proFeatures) } @@ -584,20 +638,21 @@ private final class SessionProManagerSyncState { // MARK: - SessionProManagerType public protocol SessionProManagerType: SessionProUIManagerType { - var proFeatures: SessionPro.Features { get } var plans: [SessionPro.Plan] { get } nonisolated var characterLimit: Int { get } nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } - nonisolated var currentUserCurrentBackendProStatus: Network.SessionPro.BackendUserProStatus? { get } + nonisolated var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } + nonisolated var currentUserCurrentProFeatures: SessionPro.Features? { get } nonisolated var loadingState: AsyncStream { get } - nonisolated var backendUserProStatus: AsyncStream { get } - nonisolated var proProof: AsyncStream { get } + nonisolated var proStatus: AsyncStream { get } nonisolated var autoRenewing: AsyncStream { get } nonisolated var accessExpiryTimestampMs: AsyncStream { get } nonisolated var latestPaymentItem: AsyncStream { get } + nonisolated var latestPaymentOriginatingPlatform: AsyncStream { get } + nonisolated var isRefunding: AsyncStream { get } nonisolated func proStatus( for proof: Network.SessionPro.ProProof?, @@ -614,6 +669,7 @@ public protocol SessionProManagerType: SessionProUIManagerType { func refreshProState() async throws func addProPayment(transactionId: String) async throws + func requestRefund(scene: UIWindowScene) async throws } public extension SessionProManagerType { @@ -622,6 +678,21 @@ public extension SessionProManagerType { } } +// MARK: - Convenience + +extension SessionProUI.ClientPlatform { + /// The originating platform the latest payment came from + /// + /// **Note:** There may not be a latest payment, in which case we default to `iOS` because we are on an `iOS` device + init(_ provider: Network.SessionPro.PaymentProvider?) { + switch provider { + case .none: self = .iOS + case .appStore: self = .iOS + case .playStore: self = .android + } + } +} + // MARK: - Observations // stringlint:ignore_contents @@ -637,14 +708,7 @@ public extension ObservableKey { return ObservableKey.stream( key: "currentUserProStatus", generic: .currentUserProStatus - ) { [weak manager] in manager?.backendUserProStatus } - } - - static func currentUserProProof(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProProof", - generic: .currentUserProProof - ) { [weak manager] in manager?.proProof } + ) { [weak manager] in manager?.proStatus } } static func currentUserProAutoRenewing(_ manager: SessionProManagerType) -> ObservableKey { @@ -667,14 +731,151 @@ public extension ObservableKey { generic: .currentUserProLatestPaymentItem ) { [weak manager] in manager?.latestPaymentItem } } + + static func currentUserLatestPaymentOriginatingPlatform(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserLatestPaymentOriginatingPlatform", + generic: .currentUserLatestPaymentOriginatingPlatform + ) { [weak manager] in manager?.latestPaymentOriginatingPlatform } + } + + static func currentUserProIsRefunding(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProIsRefunding", + generic: .currentUserProIsRefunding + ) { [weak manager] in manager?.isRefunding } + } } // stringlint:ignore_contents public extension GenericObservableKey { static let currentUserProLoadingState: GenericObservableKey = "currentUserProLoadingState" static let currentUserProStatus: GenericObservableKey = "currentUserProStatus" - static let currentUserProProof: GenericObservableKey = "currentUserProProof" static let currentUserProAutoRenewing: GenericObservableKey = "currentUserProAutoRenewing" static let currentUserProAccessExpiryTimestampMs: GenericObservableKey = "currentUserProAccessExpiryTimestampMs" static let currentUserProLatestPaymentItem: GenericObservableKey = "currentUserProLatestPaymentItem" + static let currentUserLatestPaymentOriginatingPlatform: GenericObservableKey = "currentUserLatestPaymentOriginatingPlatform" + static let currentUserProIsRefunding: GenericObservableKey = "currentUserProIsRefunding" +} + +// MARK: - Mocking + +private extension SessionProManager { + private func startProStatusObservations() { + proMockingObservationTask = ObservationBuilder + .initialValue(MockState(using: dependencies)) + .debounce(for: .milliseconds(10)) + .using(dependencies: dependencies) + .query { previousValue, _, _, dependencies in + MockState(previousInfo: previousValue.info, using: dependencies) + } + .assign { [weak self] state in + Task.detached(priority: .userInitiated) { + /// If the entire Session Pro feature is disabled then clear any state + guard state.info.sessionProEnabled else { + self?.syncState.update( + rotatingKeyPair: .set(to: nil), + proStatus: .set(to: nil), + proProof: .set(to: nil), + proFeatures: .set(to: .none) + ) + + await self?.loadingStateStream.send(.loading) + await self?.proStatusStream.send(nil) + await self?.autoRenewingStream.send(nil) + await self?.accessExpiryTimestampMsStream.send(nil) + await self?.latestPaymentItemStream.send(nil) + await self?.sendUpdatedIsRefundingState() + return + } + + let needsStateRefresh: Bool = { + /// We we just enabled Session Pro then we need to fetch the users state + if state.info.sessionProEnabled && state.previousInfo?.sessionProEnabled == false { + return true + } + + /// If any of the mock states changed from a mock to use the actual value then we need to know what the + /// actual value is (so need to refresh the state) + switch (state.previousInfo?.mockProBackendStatus, state.info.mockProBackendStatus) { + case (.simulate, .useActual): return true + default: break + } + + switch (state.previousInfo?.mockProLoadingState, state.info.mockProLoadingState) { + case (.simulate, .useActual): return true + default: break + } + + switch (state.previousInfo?.mockIsRefunding, state.info.mockIsRefunding) { + case (.simulate, .useActual): return true + default: break + } + + return false + }() + + /// If we need a state refresh then start a new task to do so (we don't want the mocking to be dependant on the + /// result of the refresh so don't wait for it to complete before doing any mock changes) + if needsStateRefresh { + Task.detached { [weak self] in try await self?.refreshProState() } + } + + /// While it would be easier to just rely on `refreshProState` to update the mocked statuses, that would + /// mean the mocking requires network connectivity which isn't ideal, so we also explicitly send out any mock + /// changes separately + if state.info.mockProBackendStatus != state.previousInfo?.mockProBackendStatus { + switch state.info.mockProBackendStatus { + case .useActual: break + case .simulate(let value): + self?.syncState.update(proStatus: .set(to: value)) + await self?.proStatusStream.send(value) + await self?.sendUpdatedIsRefundingState() + } + } + + if state.info.mockProLoadingState != state.previousInfo?.mockProLoadingState { + switch state.info.mockProLoadingState { + case .useActual: break + case .simulate(let value): await self?.loadingStateStream.send(value) + } + } + + if state.info.mockIsRefunding != state.previousInfo?.mockIsRefunding { + switch state.info.mockIsRefunding { + case .useActual: break + case .simulate(let value): await self?.isRefundingStream.send(value) + } + } + } + } + } + + private func mockedIfNeeded(_ value: SessionPro.LoadingState) -> SessionPro.LoadingState { + switch dependencies[feature: .mockCurrentUserSessionProLoadingState] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return value + } + } + + private func mockedIfNeeded(_ value: Network.SessionPro.BackendUserProStatus) -> Network.SessionPro.BackendUserProStatus { + switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return value + } + } + + private func mockedIfNeeded(_ value: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatform { + switch dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return value + } + } + + private func mockedIfNeeded(_ value: SessionPro.IsRefunding) -> SessionPro.IsRefunding { + switch dependencies[feature: .mockCurrentUserSessionProIsRefunding] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return value + } + } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProClientPlatform.swift b/SessionMessagingKit/SessionPro/Types/SessionProClientPlatform.swift deleted file mode 100644 index 56c4e55e92..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProClientPlatform.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -// TODO: [PRO] Move these strings - -public extension SessionPro { - enum ClientPlatform: Sendable, CaseIterable, Equatable, CustomStringConvertible { - case iOS - case android - - public var store: String { - switch self { - case .iOS: return "Apple App" - case .android: return "Google Play" - } - } - - public var account: String { - switch self { - case .iOS: return "Apple Account" - case .android: return "Google Account" - } - } - - public var deviceType: String { - switch self { - case .iOS: return "iOS" - case .android: return "Android" - } - } - - public var name: String { - switch self { - case .iOS: return "Apple" - case .android: return "Google" - } - } - - public var description: String { - switch self { - case .iOS: return "iOS" - case .android: return "Android" - } - } - } -} - -// MARK: - MockableFeature - -public extension FeatureStorage { - static let mockProOriginatingPlatform: FeatureConfig> = Dependencies.create( - identifier: "mockProOriginatingPlatform" - ) -} - -extension SessionPro.ClientPlatform: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .iOS: return "The Session Pro subscription was originally purchased on an iOS device." - case .android: return "The Session Pro subscription was originally purchased on an Android device." - } - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift index 6ba26b9d23..d9fee2789e 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -10,12 +10,6 @@ public extension SessionPro { let proProof: Network.SessionPro.ProProof let features: Features - public static let nonPro: DecodedProForMessage = DecodedProForMessage( - status: .none, - proProof: Network.SessionPro.ProProof(), - features: .none - ) - // MARK: - Initialization init(status: SessionPro.DecodedStatus, proProof: Network.SessionPro.ProProof, features: Features) { diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift index de7ac62924..0bd9bc99ff 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedStatus.swift @@ -8,30 +8,19 @@ import SessionUtilitiesKit public extension SessionPro { enum DecodedStatus: Sendable, Codable, CaseIterable { - case none case invalidProBackendSig case invalidUserSig case valid case expired - var libSessionValue: SESSION_PROTOCOL_PRO_STATUS { - switch self { - case .none: return SESSION_PROTOCOL_PRO_STATUS_NIL - case .invalidProBackendSig: return SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG - case .invalidUserSig: return SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG - case .valid: return SESSION_PROTOCOL_PRO_STATUS_VALID - case .expired: return SESSION_PROTOCOL_PRO_STATUS_EXPIRED - } - } - - public init(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { + public init?(_ libSessionValue: SESSION_PROTOCOL_PRO_STATUS) { switch libSessionValue { - case SESSION_PROTOCOL_PRO_STATUS_NIL: self = .none + case SESSION_PROTOCOL_PRO_STATUS_NIL: return nil case SESSION_PROTOCOL_PRO_STATUS_INVALID_PRO_BACKEND_SIG: self = .invalidProBackendSig case SESSION_PROTOCOL_PRO_STATUS_INVALID_USER_SIG: self = .invalidUserSig case SESSION_PROTOCOL_PRO_STATUS_VALID: self = .valid case SESSION_PROTOCOL_PRO_STATUS_EXPIRED: self = .expired - default: self = .none + default: return nil } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift b/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift index 69e203f5d4..64add28d11 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProLoadingState.swift @@ -20,23 +20,3 @@ public extension SessionPro { } } } - -// MARK: - MockableFeature - -public extension FeatureStorage { - static let mockCurrentUserSessionProLoadingState: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProLoadingState" - ) -} - -extension SessionPro.LoadingState: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .loading: return "The UI state while we are waiting on the network response." - case .error: return "The UI state when there was an error retrieving the users Pro status." - case .success: return "The UI state once we have successfully retrieved the users Pro status." - } - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift b/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift new file mode 100644 index 0000000000..71433d3a62 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift @@ -0,0 +1,73 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionUtil +import SessionUtilitiesKit + +public extension SessionPro { + enum Metadata { + private static let providerMetadata: [session_pro_backend_payment_provider_metadata] = [ + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.0, /// Empty + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.1, /// Google + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.2 /// Apple + ] + + public static let urls: GeneralUrls = GeneralUrls(SESSION_PRO_URLS) + public static let appStore: PaymentProvider = PaymentProvider(providerMetadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE.rawValue)]) + public static let playStore: PaymentProvider = PaymentProvider(providerMetadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE.rawValue)]) + } +} + +public extension SessionPro.Metadata { + struct GeneralUrls: SessionProUI.UrlStringProvider { + public let roadmap: String + public let privacyPolicy: String + public let termsOfService: String + public let proAccessNotFound: String + public let support: String + + fileprivate init(_ libSessionValue: session_pro_urls) { + self.roadmap = libSessionValue.get(\.roadmap) + self.privacyPolicy = libSessionValue.get(\.privacy_policy) + self.termsOfService = libSessionValue.get(\.terms_of_service) + self.proAccessNotFound = libSessionValue.get(\.pro_access_not_found) + self.support = libSessionValue.get(\.support_url) + } + } + + struct PaymentProvider: SessionProUI.ClientPlatformStringProvider { + public let device: String + public let store: String + public let platform: String + public let platformAccount: String + public let refundPlatformUrl: String + + /// Some platforms disallow a refund via their native support channels after some time period + /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers + /// themselves). If a platform does not have this restriction, this URL is typically the same as + /// the `refund_platform_url`. + public let refundSupportUrl: String + + public let refundStatusUrl: String + public let updateSubscriptionUrl: String + public let cancelSubscriptionUrl: String + + fileprivate init(_ libSessionValue: session_pro_backend_payment_provider_metadata) { + self.device = libSessionValue.get(\.device) + self.store = libSessionValue.get(\.store) + self.platform = libSessionValue.get(\.platform) + self.platformAccount = libSessionValue.get(\.platform_account) + self.refundPlatformUrl = libSessionValue.get(\.refund_platform_url) + + self.refundSupportUrl = libSessionValue.get(\.refund_support_url) + + self.refundStatusUrl = libSessionValue.get(\.refund_status_url) + self.updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) + self.cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) + } + } +} + +extension session_pro_urls: @retroactive CAccessible {} +extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift index 241c9366c9..aae341b29e 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit public extension SessionPro { struct Plan: Equatable, Sendable { + // stringlint:ignore_contents private static let productIds: [String] = [ "com.getsession.org.pro_sub_1_month", "com.getsession.org.pro_sub_3_months", @@ -31,7 +32,7 @@ public extension SessionPro { #if targetEnvironment(simulator) return [ Plan( - id: "SimId3", + id: "SimId3", // stringlint:ignore variant: .twelveMonths, durationMonths: 12, price: 111, @@ -39,7 +40,7 @@ public extension SessionPro { discountPercent: 75 ), Plan( - id: "SimId2", + id: "SimId2", // stringlint:ignore variant: .threeMonths, durationMonths: 3, price: 222, @@ -47,7 +48,7 @@ public extension SessionPro { discountPercent: 50 ), Plan( - id: "SimId1", + id: "SimId1", // stringlint:ignore variant: .oneMonth, durationMonths: 1, price: 444, diff --git a/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift new file mode 100644 index 0000000000..e7c5f66b06 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SessionPro { + enum IsRefunding: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { + case notRefunding + case refunding + + public init(booleanLiteral value: Bool) { + self = (value ? .refunding : .notRefunding) + } + + public init(_ value: Bool) { + self = IsRefunding(booleanLiteral: value) + } + + // stringlint:ignore_contents + public var description: String { + switch self { + case .notRefunding: return "Not Refunding" + case .refunding: return "Refunding" + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift new file mode 100644 index 0000000000..ca243f7384 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift @@ -0,0 +1,132 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +// MARK: - SessionProManager.MockState + +internal extension SessionProManager { + struct MockState: ObservableKeyProvider { + struct Info: Sendable, Equatable { + let sessionProEnabled: Bool + let mockProLoadingState: MockableFeature + let mockProBackendStatus: MockableFeature + let mockOriginatingPlatform: MockableFeature + let mockIsRefunding: MockableFeature + } + + let previousInfo: Info? + let info: Info + + let observedKeys: Set = [ + .feature(.sessionProEnabled), + .feature(.mockCurrentUserSessionProLoadingState), + .feature(.mockCurrentUserSessionProBackendStatus), + .feature(.mockCurrentUserSessionProOriginatingPlatform), + .feature(.mockCurrentUserSessionProIsRefunding) + ] + + init(previousInfo: Info? = nil, using dependencies: Dependencies) { + self.previousInfo = previousInfo + self.info = Info( + sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], + mockOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], + mockIsRefunding: dependencies[feature: .mockCurrentUserSessionProIsRefunding] + ) + } + } +} + + +// MARK: - SessionPro.LoadingState + +public extension FeatureStorage { + static let mockCurrentUserSessionProLoadingState: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProLoadingState" + ) +} + +extension SessionPro.LoadingState: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .loading: return "The UI state while we are waiting on the network response." + case .error: return "The UI state when there was an error retrieving the users Pro status." + case .success: return "The UI state once we have successfully retrieved the users Pro status." + } + } +} + +// MARK: - Network.SessionPro.BackendUserProStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProBackendStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBackendStatus" + ) +} + +extension Network.SessionPro.BackendUserProStatus: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .neverBeenPro: return "The user has never had Session Pro before." + case .active: return "The user has an active Session Pro subscription." + case .expired: return "The user's Session Pro subscription has expired." + } + } +} + +// MARK: - SessionProUI.ClientPlatform + +public extension FeatureStorage { + static let mockCurrentUserSessionProOriginatingPlatform: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProOriginatingPlatform" + ) +} + +extension SessionProUI.ClientPlatform: @retroactive CustomStringConvertible { + public var description: String { + switch self { + case .iOS: return SessionPro.Metadata.appStore.device + case .android: return SessionPro.Metadata.playStore.device + } + } +} + +extension SessionProUI.ClientPlatform: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .iOS: return "The Session Pro subscription was originally purchased on an iOS device." + case .android: return "The Session Pro subscription was originally purchased on an Android device." + } + } +} + +// MARK: - SessionPro.IsRefunding + +public extension FeatureStorage { + static let mockCurrentUserSessionProIsRefunding: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProIsRefunding" + ) +} + +extension SessionPro.IsRefunding: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .notRefunding: return "The Session Pro subscription does not currently have a pending refund." + case .refunding: return "The Session Pro subscription currently has a pending refund." + } + } +} diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 572dafeb68..6bb09d1a15 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -78,7 +78,13 @@ public extension Profile { } } - private struct ProfileProState: Equatable { + struct ProState: Equatable { + public static let nonPro: ProState = ProState( + features: .none, + expiryUnixTimestampMs: 0, + genIndexHashHex: nil + ) + let features: SessionPro.Features let expiryUnixTimestampMs: UInt64 let genIndexHashHex: String? @@ -87,6 +93,26 @@ public extension Profile { expiryUnixTimestampMs > 0 && genIndexHashHex != nil } + + init( + features: SessionPro.Features, + expiryUnixTimestampMs: UInt64, + genIndexHashHex: String? + ) { + self.features = features + self.expiryUnixTimestampMs = expiryUnixTimestampMs + self.genIndexHashHex = genIndexHashHex + } + + init?(_ decodedPro: SessionPro.DecodedProForMessage?) { + guard let decodedPro: SessionPro.DecodedProForMessage = decodedPro else { + return nil + } + + self.features = decodedPro.features + self.expiryUnixTimestampMs = decodedPro.proProof.expiryUnixTimestampMs + self.genIndexHashHex = decodedPro.proProof.genIndexHash.toHexString() + } } static func isTooLong(profileName: String) -> Bool { @@ -137,25 +163,17 @@ public extension Profile { do { let userSessionId: SessionId = dependencies[cache: .general].sessionId let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - let proUpdate: TargetUserUpdate = await { - let maybeProof: Network.SessionPro.ProProof? = await dependencies[singleton: .sessionProManager] - .proProof - .first(defaultValue: nil) - + let proUpdate: TargetUserUpdate = { guard let targetFeatures: SessionPro.Features = proFeatures, - let proof: Network.SessionPro.ProProof = maybeProof, - dependencies[singleton: .sessionProManager].proProofIsActive( - for: proof, - atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) + let proof: Network.SessionPro.ProProof = dependencies[singleton: .sessionProManager].currentUserCurrentProProof else { return .none } return .currentUserUpdate( - SessionPro.DecodedProForMessage( - status: .valid, - proProof: proof, - features: targetFeatures + ProState( + features: targetFeatures, + expiryUnixTimestampMs: proof.expiryUnixTimestampMs, + genIndexHashHex: proof.genIndexHash.toHexString() ) ) }() @@ -183,7 +201,7 @@ public extension Profile { displayPictureUpdate: DisplayPictureManager.Update = .none, nicknameUpdate: Update = .useExisting, blocksCommunityMessageRequests: Update = .useExisting, - proUpdate: TargetUserUpdate = .none, + proUpdate: TargetUserUpdate = .none, profileUpdateTimestamp: TimeInterval?, cacheSource: CacheSource = .libSession(fallback: .database), suppressUserProfileConfigUpdate: Bool = false, @@ -192,7 +210,7 @@ public extension Profile { ) throws { let isCurrentUser = currentUserSessionIds.contains(publicKey) let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) - let proState: ProfileProState = ProfileProState( + let proState: ProState = ProState( features: profile.proFeatures, expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs, genIndexHashHex: profile.proGenIndexHashHex @@ -202,7 +220,7 @@ public extension Profile { cachedProfile: profile ) var updatedProfile: Profile = profile - var updatedProState: ProfileProState = proState + var updatedProState: ProState = proState var profileChanges: [ConfigColumnAssignment] = [] /// We should only update profile info controled by other users if `updateStatus` is `shouldUpdate` @@ -292,23 +310,7 @@ public extension Profile { switch (proUpdate, isCurrentUser) { case (.none, _): break case (.contactUpdate(let value), false), (.currentUserUpdate(let value), true): - let proInfo: SessionPro.DecodedProForMessage = (value ?? .nonPro) - - switch proInfo.status { - case .valid: - updatedProState = ProfileProState( - features: proInfo.features.profileOnlyFeatures, - expiryUnixTimestampMs: proInfo.proProof.expiryUnixTimestampMs, - genIndexHashHex: proInfo.proProof.genIndexHash.toHexString() - ) - - default: - updatedProState = ProfileProState( - features: .none, - expiryUnixTimestampMs: 0, - genIndexHashHex: nil - ) - } + updatedProState = (value ?? .nonPro) /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break @@ -317,19 +319,20 @@ public extension Profile { /// Update the pro state based on whether the updated display picture is animated or not if isCurrentUser, case .currentUserUpdateTo(_, _, let type) = displayPictureUpdate { switch type { + case .reupload, .config: break /// Don't modify the current state case .staticImage: - updatedProState = ProfileProState( + updatedProState = ProState( features: updatedProState.features.removing(.animatedAvatar), expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, genIndexHashHex: updatedProState.genIndexHashHex ) + case .animatedImage: - updatedProState = ProfileProState( + updatedProState = ProState( features: updatedProState.features.inserting(.animatedAvatar), expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, genIndexHashHex: updatedProState.genIndexHashHex ) - case .reupload, .config: break /// Don't modify the current state } } diff --git a/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift b/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift index 5fdb987b04..6dc32c3e2b 100644 --- a/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift +++ b/SessionNetworkingKit/PushNotification/PushNotificationEndpoint.swift @@ -3,7 +3,6 @@ // stringlint:disable import Foundation -import SessionNetworkingKit import SessionUtilitiesKit public extension Network.PushNotification { diff --git a/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift b/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift index 332a8bb34a..79dabc50b3 100644 --- a/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift +++ b/SessionNetworkingKit/SOGS/Models/PinnedMessage.swift @@ -3,7 +3,7 @@ import Foundation extension Network.SOGS { - public struct PinnedMessage: Codable, Equatable { + public struct PinnedMessage: Sendable, Codable, Equatable { enum CodingKeys: String, CodingKey { case id case pinnedAt = "pinned_at" diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift index 51b4f4b6de..3d5a615157 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkEndpoint.swift @@ -1,11 +1,11 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. // -// stringlint:ignore +// stringlint:disable import Foundation public extension Network.SessionNetwork { - public enum Endpoint: EndpointType { + enum Endpoint: EndpointType { case info case price case token diff --git a/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedRequest.swift b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedRequest.swift new file mode 100644 index 0000000000..5c247a13dd --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedRequest.swift @@ -0,0 +1,43 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct SetPaymentRefundRequestedRequest: Encodable, Equatable { + public let masterPublicKey: [UInt8] + public let masterSignature: Signature + public let timestampMs: UInt64 + public let refundRequestedTimestampMs: UInt64 + public let transaction: UserTransaction + + // MARK: - Functions + + func toLibSession() -> session_pro_backend_set_payment_refund_requested_request { + var result: session_pro_backend_set_payment_refund_requested_request = session_pro_backend_set_payment_refund_requested_request() + result.version = Network.SessionPro.apiVersion + result.set(\.master_pkey, to: masterPublicKey) + result.set(\.master_sig, to: masterSignature.signature) + result.unix_ts_ms = timestampMs + result.refund_requested_unix_ts_ms = refundRequestedTimestampMs + result.payment_tx = transaction.toLibSession() + + return result + } + + public func encode(to encoder: any Encoder) throws { + var cRequest: session_pro_backend_set_payment_refund_requested_request = toLibSession() + var cJson: session_pro_backend_to_json = session_pro_backend_set_payment_refund_requested_request_to_json(&cRequest); + defer { session_pro_backend_to_json_free(&cJson) } + + guard cJson.success else { throw NetworkError.invalidPayload } + + let jsonData: Data = Data(bytes: cJson.json.data, count: cJson.json.size) + let decoded: [String: AnyCodable] = try JSONDecoder().decode([String: AnyCodable].self, from: jsonData) + try decoded.encode(to: encoder) + } + } +} + +extension session_pro_backend_set_payment_refund_requested_request: @retroactive CMutable {} diff --git a/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift new file mode 100644 index 0000000000..1cb6ab5ee7 --- /dev/null +++ b/SessionNetworkingKit/SessionPro/Requests/SetPaymentRefundRequestedResponse.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil +import SessionUtilitiesKit + +public extension Network.SessionPro { + struct SetPaymentRefundRequestedResponse: Decodable, Equatable { + public let header: ResponseHeader + public let version: UInt8 + public let updated: Bool + + public init(from decoder: any Decoder) throws { + let container: SingleValueDecodingContainer = try decoder.singleValueContainer() + let jsonData: Data + + if let data: Data = try? container.decode(Data.self) { + jsonData = data + } + else if let jsonString: String = try? container.decode(String.self) { + guard let data: Data = jsonString.data(using: .utf8) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid UTF-8 in JSON string" // stringlint:ignore + ) + } + + jsonData = data + } + else { + let anyValue: AnyCodable = try container.decode(AnyCodable.self) + jsonData = try JSONEncoder().encode(anyValue) + } + + var result = jsonData.withUnsafeBytes { bytes in + session_pro_backend_set_payment_refund_requested_response_parse( + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + jsonData.count + ) + } + defer { session_pro_backend_set_payment_refund_requested_response_free(&result) } + + self.header = ResponseHeader(result.header) + self.version = result.version + self.updated = result.updated + } + } +} diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 6a44df212b..39785eb648 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -209,4 +209,50 @@ public extension Network.SessionPro { using: dependencies ) } + + static func setPaymentRefundRequested( + transactionId: String, + refundRequestedTimestampMs: UInt64, + masterKeyPair: KeyPair, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey + let timestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let cTransactionId: [UInt8] = Array(transactionId.utf8) + let signature: Signature = try Signature( + session_pro_backend_set_payment_refund_requested_request_build_sigs( + Network.SessionPro.apiVersion, + cMasterPrivateKey, + cMasterPrivateKey.count, + timestampMs, + refundRequestedTimestampMs, + PaymentProvider.appStore.libSessionValue, + cTransactionId, + cTransactionId.count, + [], /// The `order_id` is only needed for Google transactions + 0 + ) + ) + + return try Network.PreparedRequest( + request: try Request( + method: .post, + endpoint: .getProRevocations, + body: SetPaymentRefundRequestedRequest( + masterPublicKey: masterKeyPair.publicKey, + masterSignature: signature, + timestampMs: timestampMs, + refundRequestedTimestampMs: refundRequestedTimestampMs, + transaction: UserTransaction( + provider: .appStore, + paymentId: transactionId, + orderId: "" /// The `order_id` is only needed for Google transactions + ), + ), + using: dependencies + ), + responseType: SetPaymentRefundRequestedResponse.self, + using: dependencies + ) + } } diff --git a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift index ecd21e3481..15acce7eda 100644 --- a/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift +++ b/SessionNetworkingKit/SessionPro/SessionProEndpoint.swift @@ -10,6 +10,7 @@ public extension Network.SessionPro { case generateProProof case getProRevocations case getProDetails + case setPaymentRefundRequested public static var name: String { "SessionPro.Endpoint" } @@ -19,6 +20,7 @@ public extension Network.SessionPro { case .generateProProof: return "generate_pro_proof" case .getProRevocations: return "get_pro_revocations" case .getProDetails: return "get_pro_details" + case .setPaymentRefundRequested: return "set_payment_refund_requested" } } } diff --git a/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift index 2550b7b94d..968e106d42 100644 --- a/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift +++ b/SessionNetworkingKit/SessionPro/Types/BackendUserProStatus.swift @@ -38,23 +38,3 @@ public extension Network.SessionPro { } } } - -// MARK: - MockableFeature - -public extension FeatureStorage { - static let mockCurrentUserSessionProBackendStatus: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProBackendStatus" - ) -} - -extension Network.SessionPro.BackendUserProStatus: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .neverBeenPro: return "The user has never had Session Pro before." - case .active: return "The user has an active Session Pro subscription." - case .expired: return "The user's Session Pro subscription has expired." - } - } -} diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift index 9c5c1eeb51..0cb53a8fd6 100644 --- a/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift +++ b/SessionNetworkingKit/SessionPro/Types/PaymentItem.swift @@ -9,27 +9,26 @@ public extension Network.SessionPro { public let status: PaymentStatus public let plan: Plan public let paymentProvider: PaymentProvider? - public let paymentProviderMetadata: PaymentProviderMetadata? public let autoRenewing: Bool - let unredeemedTimestampMs: UInt64 - let redeemedTimestampMs: UInt64 - let expiryTimestampMs: UInt64 - let gracePeriodDurationMs: UInt64 - let platformRefundExpiryTimestampMs: UInt64 - let revokedTimestampMs: UInt64 + public let unredeemedTimestampMs: UInt64 + public let redeemedTimestampMs: UInt64 + public let expiryTimestampMs: UInt64 + public let gracePeriodDurationMs: UInt64 + public let platformRefundExpiryTimestampMs: UInt64 + public let revokedTimestampMs: UInt64 + public let refundRequestedTimestampMs: UInt64 - let googlePaymentToken: String? - let googleOrderId: String? - let appleOriginalTransactionId: String? - let appleTransactionId: String? - let appleWebLineOrderId: String? + public let googlePaymentToken: String? + public let googleOrderId: String? + public let appleOriginalTransactionId: String? + public let appleTransactionId: String? + public let appleWebLineOrderId: String? init(_ libSessionValue: session_pro_backend_pro_payment_item) { status = PaymentStatus(libSessionValue.status) plan = Plan(libSessionValue.plan) paymentProvider = PaymentProvider(libSessionValue.payment_provider) - paymentProviderMetadata = PaymentProviderMetadata(libSessionValue.payment_provider_metadata) autoRenewing = libSessionValue.auto_renewing unredeemedTimestampMs = libSessionValue.unredeemed_unix_ts_ms @@ -38,6 +37,7 @@ public extension Network.SessionPro { gracePeriodDurationMs = libSessionValue.grace_period_duration_ms platformRefundExpiryTimestampMs = libSessionValue.platform_refund_expiry_unix_ts_ms revokedTimestampMs = libSessionValue.revoked_unix_ts_ms + refundRequestedTimestampMs = libSessionValue.refund_requested_unix_ts_ms googlePaymentToken = libSessionValue.get( \.google_payment_token, diff --git a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift b/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift deleted file mode 100644 index 2673d86e6a..0000000000 --- a/SessionNetworkingKit/SessionPro/Types/PaymentProviderMetadata.swift +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil -import SessionUtilitiesKit - -public extension Network.SessionPro { - struct PaymentProviderMetadata: Sendable, Equatable, Hashable { - let device: String - let store: String - let platform: String - let platformAccount: String - let refundPlatformUrl: String - - /// Some platforms disallow a refund via their native support channels after some time period - /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers - /// themselves). If a platform does not have this restriction, this URL is typically the same as - /// the `refundPlatformUrl`. - let refundSupportUrl: String - - let refundStatusUrl: String - let updateSubscriptionUrl: String - let cancelSubscriptionUrl: String - - init?(_ pointer: UnsafePointer?) { - guard let libSessionValue: session_pro_backend_payment_provider_metadata = pointer?.pointee else { - return nil - } - - device = libSessionValue.get(\.device) - store = libSessionValue.get(\.store) - platform = libSessionValue.get(\.platform) - platformAccount = libSessionValue.get(\.platform_account) - refundPlatformUrl = libSessionValue.get(\.refund_platform_url) - refundSupportUrl = libSessionValue.get(\.refund_support_url) - refundStatusUrl = libSessionValue.get(\.refund_status_url) - updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) - cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) - } - } -} - -extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 9782f5c6a9..f87ceb8fec 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -758,4 +758,15 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { @MainActor func numberOfCharactersLeft(for text: String) -> Int { return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } + + func proUrlStringProvider() -> SessionProUI.UrlStringProvider { + return SessionPro.Metadata.urls + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + switch platform { + case .iOS: return SessionPro.Metadata.appStore + case .android: return SessionPro.Metadata.playStore + } + } } diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index cd07787aac..7f647ebab0 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -27,6 +27,9 @@ public actor SNUIKit { func mediaDecoderSource(for data: Data) -> CGImageSource? @MainActor func numberOfCharactersLeft(for text: String) -> Int + + func proUrlStringProvider() -> SessionProUI.UrlStringProvider + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider } @MainActor public static var mainWindow: UIWindow? = nil @@ -139,4 +142,24 @@ public actor SNUIKit { return (config?.numberOfCharactersLeft(for: text) ?? 0) } + + internal static func proUrlStringProvider() -> SessionProUI.UrlStringProvider { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.proUrlStringProvider() ?? + SessionProUI.FallbackUrlStringProvider() + ) + } + + internal static func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.proClientPlatformStringProvider(for: platform) ?? + SessionProUI.FallbackClientPlatformStringProvider() + ) + } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+CancelPlan.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+CancelPlan.swift index 3201444b97..d9270e0287 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+CancelPlan.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+CancelPlan.swift @@ -62,7 +62,7 @@ struct CancelPlanOriginatingPlatformContent: View { // MARK: - Cancel Plan Non Originating Platform Content struct CancelPlanNonOriginatingPlatformContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openPlatformStoreWebsiteAction: () -> Void var body: some View { @@ -83,7 +83,7 @@ struct CancelPlanNonOriginatingPlatformContent: View { "proCancellationDescription" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) ) .font(.Body.baseRegular) @@ -100,12 +100,12 @@ struct CancelPlanNonOriginatingPlatformContent: View { ApproachCell( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -114,10 +114,10 @@ struct CancelPlanNonOriginatingPlatformContent: View { ApproachCell( title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index fe02ccc2e1..329bcbdc05 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -11,17 +11,17 @@ public extension SessionProPaymentScreenContent { currentPlan: SessionProPlanInfo, expiredOn: Date, isAutoRenewing: Bool, - originatingPlatform: ClientPlatform + originatingPlatform: SessionProUI.ClientPlatform ) case renew( - originatingPlatform: ClientPlatform + originatingPlatform: SessionProUI.ClientPlatform ) case refund( - originatingPlatform: ClientPlatform, + originatingPlatform: SessionProUI.ClientPlatform, requestedAt: Date? ) case cancel( - originatingPlatform: ClientPlatform + originatingPlatform: SessionProUI.ClientPlatform ) var description: ThemedAttributedString { @@ -63,39 +63,6 @@ public extension SessionProPaymentScreenContent { } } - enum ClientPlatform: Equatable { - case iOS - case android - - public var store: String { - switch self { - case .iOS: return Constants.platform_store - case .android: return Constants.android_platform_store - } - } - - public var account: String { - switch self { - case .iOS: return Constants.platform_account - case .android: return Constants.android_platform_account - } - } - - public var deviceType: String { - switch self { - case .iOS: return Constants.platform - case .android: return Constants.android_platform - } - } - - public var name: String { - switch self { - case .iOS: return Constants.platform_name - case .android: return Constants.android_platform_name - } - } - } - struct SessionProPlanInfo: Equatable { public let duration: Int let totalPrice: Double diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift index 76b4ac91e9..8152ce2ab5 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Renew.swift @@ -6,7 +6,7 @@ import Lucide // MARK: - Renew Plan No Billing Access Content struct RenewPlanNoBillingAccessContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openPlatformStoreWebsiteAction: () -> Void var body: some View { @@ -30,10 +30,10 @@ struct RenewPlanNoBillingAccessContent: View { AttributedText( "proRenewingNoAccessBilling" .put(key: "pro", value: Constants.pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "app_name", value: Constants.app_name) - .put(key: "build_variant", value: Constants.IPA) + .put(key: "build_variant", value: Constants.IPA) // TODO: [PRO] source this from libSession? .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -50,8 +50,8 @@ struct RenewPlanNoBillingAccessContent: View { description: "proRenewDesktopLinked" .put(key: "app_name", value: Constants.app_name) .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform_store", value: Constants.platform_store) - .put(key: "platform_store_other", value: Constants.android_platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) + .put(key: "platform_store_other", value: SNUIKit.proClientPlatformStringProvider(for: .android).store) .put(key: "pro", value: Constants.pro) .localizedFormatted(), variant: .link @@ -61,7 +61,7 @@ struct RenewPlanNoBillingAccessContent: View { title: "proNewInstallation".localized(), description: "proNewInstallationDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -70,11 +70,11 @@ struct RenewPlanNoBillingAccessContent: View { ApproachCell( title: "onPlatformWebsite" - .put(key: "platform", value: originatingPlatform.store) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "proAccessRenewPlatformWebsite" - .put(key: "platform_account", value: originatingPlatform.account) - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform_account", value: originatingPlatform.platformAccount) + .put(key: "platform", value: originatingPlatform.platform) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), variant: .website diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift index a4650620e3..3f8a34a3be 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift @@ -25,8 +25,8 @@ struct RequestRefundOriginatingPlatformContent: View { AttributedText( "proRefundingDescription" .put(key: "app_pro", value: Constants.app_pro) - .put(key: "platform", value: Constants.platform) - .put(key: "platform_store", value: Constants.platform_store) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) + .put(key: "platform_store", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).store) .put(key: "app_name", value: Constants.app_name) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -92,7 +92,7 @@ struct RequestRefundSuccessContent: View { Text( "proRefundNextSteps" - .put(key: "platform", value: Constants.platform) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) .put(key: "pro", value: Constants.pro) .put(key: "app_name", value: Constants.app_name) .localized() @@ -108,7 +108,7 @@ struct RequestRefundSuccessContent: View { AttributedText( "proRefundSupport" - .put(key: "platform", value: Constants.platform) + .put(key: "platform", value: SNUIKit.proClientPlatformStringProvider(for: .iOS).platform) .put(key: "app_name", value: Constants.app_name) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -150,7 +150,7 @@ struct RequestRefundSuccessContent: View { // MARK: - Request Refund Non Originating Platform Content struct RequestRefundNonOriginatingPlatformContent: View { - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let requestedAt: Date? var isLessThan48Hours: Bool { (requestedAt?.timeIntervalSinceNow ?? 0) <= 48 * 60 * 60 } let openPlatformStoreWebsiteAction: () -> Void @@ -178,7 +178,7 @@ struct RequestRefundNonOriginatingPlatformContent: View { "proPlanPlatformRefund" .put(key: "app_pro", value: Constants.app_pro) .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) : "proPlanPlatformRefundLong" .put(key: "app_pro", value: Constants.app_pro) @@ -198,12 +198,12 @@ struct RequestRefundNonOriginatingPlatformContent: View { ApproachCell( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(), @@ -212,10 +212,10 @@ struct RequestRefundNonOriginatingPlatformContent: View { ApproachCell( title: "viaStoreWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift index 68e2dcdfde..3e3bfbfa36 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+UpdatePlan.swift @@ -9,7 +9,7 @@ struct UpdatePlanNonOriginatingPlatformContent: View { let currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo let currentPlanExpiredOn: Date let isAutoRenewing: Bool - let originatingPlatform: SessionProPaymentScreenContent.ClientPlatform + let originatingPlatform: SessionProUI.ClientPlatform let openPlatformStoreWebsiteAction: () -> Void var body: some View { @@ -34,7 +34,7 @@ struct UpdatePlanNonOriginatingPlatformContent: View { "proAccessSignUp" .put(key: "app_pro", value: Constants.app_pro) .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular) ) @@ -53,12 +53,12 @@ struct UpdatePlanNonOriginatingPlatformContent: View { ApproachCell( title: "onDevice" - .put(key: "device_type", value: originatingPlatform.deviceType) + .put(key: "device_type", value: originatingPlatform.device) .localized(), description: "onDeviceDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "device_type", value: originatingPlatform.deviceType) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "device_type", value: originatingPlatform.device) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), @@ -67,10 +67,10 @@ struct UpdatePlanNonOriginatingPlatformContent: View { ApproachCell( title: "viaStoreWebsite" - .put(key: "platform", value: originatingPlatform.name) + .put(key: "platform", value: originatingPlatform.platform) .localized(), description: "viaStoreWebsiteDescription" - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .put(key: "platform_store", value: originatingPlatform.store) .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular), diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 8a18076d97..5cbb53e388 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -261,8 +261,8 @@ public struct SessionProPaymentScreen: View { let modal: ModalHostingViewController = ModalHostingViewController( modal: MutipleLinksModal( links: [ - Constants.session_pro_terms_url, - Constants.session_pro_privacy_url + SNUIKit.proUrlStringProvider().termsOfService, + SNUIKit.proUrlStringProvider().privacyPolicy ], openURL: { url in viewModel.openURL(url) @@ -273,7 +273,7 @@ public struct SessionProPaymentScreen: View { } private func openPlatformStoreWebsite() { - guard let url: URL = URL(string: Constants.google_play_store_subscriptions_url) else { return } + guard let url: URL = URL(string: SNUIKit.proClientPlatformStringProvider(for: .android).cancelSubscriptionUrl) else { return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift new file mode 100644 index 0000000000..081a9e0f4d --- /dev/null +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProUI.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SessionProUI {} + +// MARK: - String Providers + +public extension SessionProUI { + protocol UrlStringProvider { + var roadmap: String { get } + var privacyPolicy: String { get } + var termsOfService: String { get } + var proAccessNotFound: String { get } + var support: String { get } + } + + protocol ClientPlatformStringProvider { + var device: String { get } + var store: String { get } + var platform: String { get } + var platformAccount: String { get } + + var refundPlatformUrl: String { get } + var refundSupportUrl: String { get } + var refundStatusUrl: String { get } + var updateSubscriptionUrl: String { get } + var cancelSubscriptionUrl: String { get } + } +} + +// MARK: - String Provider Fallbacks + +// stringlint:ignore_contents +internal extension SessionProUI { + /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) + struct FallbackUrlStringProvider: UrlStringProvider { + let roadmap: String = "https://getsession.org/pro-roadmap" + let privacyPolicy: String = "https://getsession.org/pro/privacy" + let termsOfService: String = "https://getsession.org/pro/terms" + let proAccessNotFound: String = "https://sessionapp.zendesk.com/hc/sections/4416517450649-Support" + let support: String = "https://getsession.org/pro-form" + } + + /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) + struct FallbackClientPlatformStringProvider: ClientPlatformStringProvider { + let device: String = "iOS" + let store: String = "Apple App Store" + let platform: String = "Apple" + let platformAccount: String = "Apple Account" + + let refundPlatformUrl: String = "https://support.apple.com/118223" + let refundSupportUrl: String = "https://support.apple.com/118223" + let refundStatusUrl: String = "https://support.apple.com/118224" + let updateSubscriptionUrl: String = "https://apps.apple.com/account/subscriptions" + let cancelSubscriptionUrl: String = "https://account.apple.com/account/manage/section/subscriptions" + } +} + +// MARK: - ClientPlatform + +public extension SessionProUI { + enum ClientPlatform: Sendable, Equatable, CaseIterable { + case iOS + case android + + public var device: String { SNUIKit.proClientPlatformStringProvider(for: self).device } + public var store: String { SNUIKit.proClientPlatformStringProvider(for: self).store } + public var platform: String { SNUIKit.proClientPlatformStringProvider(for: self).platform } + public var platformAccount: String { SNUIKit.proClientPlatformStringProvider(for: self).platformAccount } + } +} diff --git a/SessionUIKit/Style Guide/Constants+Apple.swift b/SessionUIKit/Style Guide/Constants+Apple.swift index f7755dcda2..304aa832bf 100644 --- a/SessionUIKit/Style Guide/Constants+Apple.swift +++ b/SessionUIKit/Style Guide/Constants+Apple.swift @@ -1,32 +1,18 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable +//// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +//// +//// stringlint:disable + public extension Constants { - // TODO: These strings will be going to be defined in libSession - // MARK: - URL + static let session_network_url = "https://docs.getsession.org/session-network" static let session_staking_url = "https://docs.getsession.org/session-network/staking" static let session_token_url = "https://token.getsession.org" static let session_donations_url = "https://session.foundation/donate#app" - static let session_pro_roadmap = "https://getsession.org/pro-roadmap" - static let session_pro_faq_url = "https://getsession.org/faq#pro" - static let session_pro_support_url = "https://getsession.org/pro-form" - static let session_pro_recovery_support_url = "https://sessionapp.zendesk.com/hc/sections/4416517450649-Support" static let session_feedback_url = "https://getsession.org/feedback" - static let app_store_refund_support = "https://support.apple.com/118224" - static let google_play_store_subscriptions_url = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger" - static let session_pro_terms_url = "https://getsession.org/pro/terms" - static let session_pro_privacy_url = "https://getsession.org/pro/privacy" + + static let session_pro_faq_url = "https://getsession.org/faq#pro" // MARK: - Names - static let platform_account = "Apple Account" - static let platform_store = "Apple App Store" - static let platform_name = "iOS" - static let platform = "Apple" - static let android_platform_account = "Google Account" - static let android_platform_store = "Google Play Store" - static let android_platform_name = "Android" - static let android_platform = "Google" static let IPA = "IPA" } diff --git a/SessionUtilitiesKit/Database/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift index 4609edfbdd..51f645916b 100644 --- a/SessionUtilitiesKit/Database/Types/Migration.swift +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -11,7 +11,7 @@ public extension Log.Category { // MARK: - Migration -public protocol Migration { +public protocol Migration: Sendable { static var identifier: String { get } static var minExpectedRunDuration: TimeInterval { get } static var createdTables: [(TableRecord & FetchableRecord).Type] { get } diff --git a/SessionUtilitiesKit/General/ScreenLock.swift b/SessionUtilitiesKit/General/ScreenLock.swift index 09843f1586..df49c37435 100644 --- a/SessionUtilitiesKit/General/ScreenLock.swift +++ b/SessionUtilitiesKit/General/ScreenLock.swift @@ -218,6 +218,10 @@ public enum ScreenLock { Log.error(.screenLock, "Context not interactive.") return .unexpectedFailure(error: defaultErrorDescription) + case .companionNotAvailable: + Log.error(.screenLock, "Companion not available.") + return .unexpectedFailure(error: defaultErrorDescription) + @unknown default: return .failure(error: defaultErrorDescription) } diff --git a/SessionUtilitiesKit/Observations/ObservableKey.swift b/SessionUtilitiesKit/Observations/ObservableKey.swift index ea70decce0..81b5d22ccc 100644 --- a/SessionUtilitiesKit/Observations/ObservableKey.swift +++ b/SessionUtilitiesKit/Observations/ObservableKey.swift @@ -55,6 +55,11 @@ public struct ObservedEvent: Hashable, Sendable { self.storedValue = value.map { AnySendableHashable($0) } } + public init(key: ObservableKey, value: AnySendableHashable) { + self.key = key + self.storedValue = value + } + public init(key: ObservableKey, value: None?) { self.key = key self.storedValue = value.map { AnySendableHashable($0) } diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 09fc34c11c..8f95923348 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -15,7 +15,7 @@ public extension Singleton { // MARK: - FileManagerType -public protocol FileManagerType { +public protocol FileManagerType: Sendable { var temporaryDirectory: String { get } var documentsDirectoryPath: String { get } var appSharedDataDirectoryPath: String { get } @@ -142,20 +142,19 @@ public extension SessionFileManager { // MARK: - SessionFileManager -public class SessionFileManager: FileManagerType { +public final class SessionFileManager: FileManagerType { private static let temporaryDirectoryPrefix: String = "sesh_temp_" private let dependencies: Dependencies - private let fileManager: FileManager = .default - public var temporaryDirectory: String + public let temporaryDirectory: String public var documentsDirectoryPath: String { - return (fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path) + return (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path) .defaulting(to: "") } public var appSharedDataDirectoryPath: String { - return (fileManager.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)?.path) + return (FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)?.path) .defaulting(to: "") } @@ -176,7 +175,7 @@ public class SessionFileManager: FileManagerType { public func clearOldTemporaryDirectories() { /// We use the lowest priority queue for this, and wait N seconds to avoid interfering with app startup - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, fileManager, dependencies] in + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, dependencies] in /// Abort if app not active guard dependencies[singleton: .appContext].isAppForegroundAndActive else { return } @@ -185,7 +184,7 @@ public class SessionFileManager: FileManagerType { let currentTempDirName: String = URL(fileURLWithPath: temporaryDirectory).lastPathComponent let dirPath: String = NSTemporaryDirectory() - guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { + guard let fileNames: [String] = try? FileManager.default.contentsOfDirectory(atPath: dirPath) else { return } @@ -202,14 +201,14 @@ public class SessionFileManager: FileManagerType { /// It's fine if we can't get the attributes (the file may have been deleted since we found it), also don't delete /// files which were created in the last N minutes guard - let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: filePath), + let attributes: [FileAttributeKey: Any] = try? FileManager.default.attributesOfItem(atPath: filePath), let modificationDate: Date = attributes[.modificationDate] as? Date, modificationDate.timeIntervalSince1970 <= thresholdDate.timeIntervalSince1970 else { return } } /// This can happen if the app launches before the phone is unlocked, clean up will occur when app becomes active - try? fileManager.removeItem(atPath: filePath) + try? FileManager.default.removeItem(atPath: filePath) } } } @@ -217,8 +216,8 @@ public class SessionFileManager: FileManagerType { public func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws { var isDirectory: ObjCBool = false - if !fileManager.fileExists(atPath: path, isDirectory: &isDirectory) { - try fileManager.createDirectory( + if !FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { + try FileManager.default.createDirectory( atPath: path, withIntermediateDirectories: true, attributes: nil @@ -229,9 +228,9 @@ public class SessionFileManager: FileManagerType { } public func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws { - guard fileManager.fileExists(atPath: path) else { return } + guard FileManager.default.fileExists(atPath: path) else { return } - try fileManager.setAttributes( + try FileManager.default.setAttributes( [.protectionKey: fileProtectionType], ofItemAtPath: path ) @@ -243,7 +242,7 @@ public class SessionFileManager: FileManagerType { } public func fileSize(of path: String) -> UInt64? { - guard let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: path) else { + guard let attributes: [FileAttributeKey: Any] = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } @@ -286,11 +285,11 @@ public class SessionFileManager: FileManagerType { // MARK: - Forwarded NSFileManager - public var currentDirectoryPath: String { fileManager.currentDirectoryPath } + public var currentDirectoryPath: String { FileManager.default.currentDirectoryPath } public func urls(for directory: FileManager.SearchPathDirectory, in domains: FileManager.SearchPathDomainMask) -> [URL] { - return fileManager.urls(for: directory, in: domains) + return FileManager.default.urls(for: directory, in: domains) } public func enumerator( @@ -299,7 +298,7 @@ public class SessionFileManager: FileManagerType { options: FileManager.DirectoryEnumerationOptions, errorHandler: ((URL, Error) -> Bool)? ) -> FileManager.DirectoryEnumerator? { - return fileManager.enumerator( + return FileManager.default.enumerator( at: url, includingPropertiesForKeys: includingPropertiesForKeys, options: options, @@ -308,28 +307,28 @@ public class SessionFileManager: FileManagerType { } public func fileExists(atPath: String) -> Bool { - return fileManager.fileExists(atPath: atPath) + return FileManager.default.fileExists(atPath: atPath) } public func fileExists(atPath: String, isDirectory: UnsafeMutablePointer?) -> Bool { - return fileManager.fileExists(atPath: atPath, isDirectory: isDirectory) + return FileManager.default.fileExists(atPath: atPath, isDirectory: isDirectory) } public func contents(atPath: String) -> Data? { - return fileManager.contents(atPath: atPath) + return FileManager.default.contents(atPath: atPath) } public func contentsOfDirectory(at url: URL) throws -> [URL] { - return try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) } public func contentsOfDirectory(atPath path: String) throws -> [String] { - return try fileManager.contentsOfDirectory(atPath: path) + return try FileManager.default.contentsOfDirectory(atPath: path) } public func isDirectoryEmpty(at url: URL) -> Bool { guard - let enumerator = fileManager.enumerator( + let enumerator = FileManager.default.enumerator( at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] @@ -344,11 +343,11 @@ public class SessionFileManager: FileManagerType { } public func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool { - return fileManager.createFile(atPath: atPath, contents: contents, attributes: attributes) + return FileManager.default.createFile(atPath: atPath, contents: contents, attributes: attributes) } public func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - return try fileManager.createDirectory( + return try FileManager.default.createDirectory( at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: attributes @@ -356,7 +355,7 @@ public class SessionFileManager: FileManagerType { } public func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws { - return try fileManager.createDirectory( + return try FileManager.default.createDirectory( atPath: atPath, withIntermediateDirectories: withIntermediateDirectories, attributes: attributes @@ -364,23 +363,23 @@ public class SessionFileManager: FileManagerType { } public func copyItem(atPath: String, toPath: String) throws { - return try fileManager.copyItem(atPath: atPath, toPath: toPath) + return try FileManager.default.copyItem(atPath: atPath, toPath: toPath) } public func copyItem(at fromUrl: URL, to toUrl: URL) throws { - return try fileManager.copyItem(at: fromUrl, to: toUrl) + return try FileManager.default.copyItem(at: fromUrl, to: toUrl) } public func moveItem(atPath: String, toPath: String) throws { - try fileManager.moveItem(atPath: atPath, toPath: toPath) + try FileManager.default.moveItem(atPath: atPath, toPath: toPath) } public func moveItem(at fromUrl: URL, to toUrl: URL) throws { - try fileManager.moveItem(at: fromUrl, to: toUrl) + try FileManager.default.moveItem(at: fromUrl, to: toUrl) } public func replaceItem(atPath originalItemPath: String, withItemAtPath newItemPath: String, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> String? { - return try fileManager.replaceItemAt( + return try FileManager.default.replaceItemAt( URL(fileURLWithPath: originalItemPath), withItemAt: URL(fileURLWithPath: newItemPath), backupItemName: backupItemName, @@ -389,18 +388,18 @@ public class SessionFileManager: FileManagerType { } public func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> URL? { - return try fileManager.replaceItemAt(originalItemURL, withItemAt: newItemURL, backupItemName: backupItemName, options: options) + return try FileManager.default.replaceItemAt(originalItemURL, withItemAt: newItemURL, backupItemName: backupItemName, options: options) } public func removeItem(atPath: String) throws { - return try fileManager.removeItem(atPath: atPath) + return try FileManager.default.removeItem(atPath: atPath) } public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { - return try fileManager.attributesOfItem(atPath: path) + return try FileManager.default.attributesOfItem(atPath: path) } public func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { - return try fileManager.setAttributes(attributes, ofItemAtPath: path) + return try FileManager.default.setAttributes(attributes, ofItemAtPath: path) } } From 173025697bbd02a32f357964bea44e9a895718de Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 27 Nov 2025 15:38:02 +1100 Subject: [PATCH 27/66] Fixed a few issues, updated for latest libSession changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Updated to latest libSession • Updated code to use urls and strings from libSession • Split the profile and message pro feature bitsets • Fixed a bunch of warnings • Fixed a bug where the Toggle ListItemAccessory could swallow touches and make it appear as though something changed when it didn't • Fixed a bug where not all profile data was getting encoded/decoded --- Session.xcodeproj/project.pbxproj | 28 +++-- .../Conversations/ConversationViewModel.swift | 21 ++-- .../SwiftUI/LinkPreviewView_SwiftUI.swift | 6 +- .../Message Cells/VisibleMessageCell.swift | 2 +- .../App Review/AppReviewPromptModel.swift | 2 +- Session/Home/HomeViewModel.swift | 8 +- .../MessageInfoScreen.swift | 23 ++-- Session/Meta/Session+SNUIKit.swift | 14 ++- Session/Onboarding/LandingScreen.swift | 4 +- .../DeveloperSettingsViewModel.swift | 2 +- Session/Settings/HelpViewModel.swift | 16 +-- .../SessionProSettingsViewModel.swift | 33 +++-- Session/Settings/SettingsViewModel.swift | 4 +- Session/Utilities/MockDataGenerator.swift | 6 +- .../Crypto/Crypto+LibSession.swift | 13 +- .../_028_GenerateInitialUserConfigDumps.swift | 2 +- .../Migrations/_048_SessionProChanges.swift | 3 +- .../Database/Models/Interaction.swift | 30 +++-- .../Database/Models/Profile.swift | 40 +++--- .../Config Handling/LibSession+Contacts.swift | 7 +- .../LibSession+GroupMembers.swift | 2 +- .../Config Handling/LibSession+Shared.swift | 15 ++- .../LibSession+UserProfile.swift | 16 +-- .../LibSession+SessionMessagingKit.swift | 6 +- SessionMessagingKit/Messages/Message.swift | 12 +- .../Visible Messages/VisibleMessage.swift | 40 ++++-- .../Protos/Generated/SNProto.swift | 30 +++-- .../Protos/Generated/SessionProtos.pb.swift | 40 ++++-- .../Protos/SessionProtos.proto | 5 +- .../MessageReceiver+Groups.swift | 2 +- .../MessageReceiver+VisibleMessages.swift | 5 +- .../Sending & Receiving/MessageReceiver.swift | 3 +- .../Sending & Receiving/MessageSender.swift | 3 +- .../Quotes/QuotedReplyModel.swift | 10 +- .../SessionPro/SessionProManager.swift | 48 ++++--- .../SessionProDecodedProForMessage.swift | 18 ++- .../SessionPro/Types/SessionProFeatures.swift | 53 -------- .../Types/SessionProFeaturesForMessage.swift | 6 +- .../Types/SessionProMessageFeatures.swift | 48 +++++++ .../SessionPro/Types/SessionProMetadata.swift | 73 ----------- .../Types/SessionProProfileFeatures.swift | 48 +++++++ .../Utilities/SessionProMocking.swift | 4 +- .../Shared Models/MessageViewModel.swift | 56 ++++++--- .../Types/Constants+LibSession.swift | 119 ++++++++++++++++++ .../Types/LinkPreviewManager.swift | 3 +- .../ObservableKey+SessionMessagingKit.swift | 2 +- .../Utilities/Profile+Updating.swift | 39 +++--- .../Types/HTTPFragmentParam+FileServer.swift | 2 + .../Types/HTTPFragmentParam.swift | 2 + .../Types/HTTPQueryParam.swift | 2 + .../ShareNavController.swift | 22 ++-- .../Components/SwiftUI/AnimatedToggle.swift | 4 + SessionUIKit/Configuration.swift | 25 ++-- .../ListItemAccessory+Toggle.swift | 1 + .../SessionNetworkScreen.swift | 4 +- .../SessionProPaymentScreen+Renew.swift | 2 +- .../SessionProPaymentScreen.swift | 4 +- .../SessionProPlanUpdatedScreen.swift | 9 +- .../SessionProSettings/SessionProUI.swift | 53 -------- .../Style Guide/Constants+Apple.swift | 18 --- SessionUIKit/Types/StringProviders.swift | 85 +++++++++++++ SessionUIKit/Utilities/QRCode.swift | 4 +- SessionUIKit/Utilities/String+Utilities.swift | 1 + SessionUtilitiesKit/Types/AnyCodable.swift | 2 +- 64 files changed, 736 insertions(+), 474 deletions(-) delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift create mode 100644 SessionMessagingKit/Types/Constants+LibSession.swift delete mode 100644 SessionUIKit/Style Guide/Constants+Apple.swift create mode 100644 SessionUIKit/Types/StringProviders.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 06b84fb862..994e6010ca 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -219,7 +219,6 @@ 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; - 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */; }; 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */; }; 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */; }; @@ -516,9 +515,11 @@ FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */; }; FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; - FD1F3CF32ED657AC00E536D5 /* SessionProMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */; }; + FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */; }; FD1F3CF62ED69B6600E536D5 /* SessionProMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */; }; FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */; }; + FD1F3CFA2ED7B34C00E536D5 /* SessionProMessageFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */; }; + FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */; }; FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */; }; FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */; }; FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */; }; @@ -1048,7 +1049,7 @@ FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */; }; FDAA36C62EB474C80040603E /* SessionProFeaturesForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */; }; FDAA36C82EB475180040603E /* SessionProFeatureStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */; }; - FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProFeatures.swift */; }; + FDAA36CA2EB476090040603E /* SessionProProfileFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */; }; FDAA36CE2EB4844F0040603E /* SessionProDecodedProForMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */; }; FDAA36D02EB485F20040603E /* SessionProDecodedStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */; }; FDAB8A832EB2A4CB000A6C65 /* MentionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C302093D25DCBF07001F572D /* MentionSelectionView.swift */; }; @@ -1723,7 +1724,6 @@ 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+CopyButton.swift"; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; - 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Modal+SwiftUI.swift"; sourceTree = ""; }; 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProCTAModal.swift; sourceTree = ""; }; 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+SwiftUI.swift"; sourceTree = ""; }; @@ -2028,9 +2028,11 @@ FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedRequest.swift; sourceTree = ""; }; FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedResponse.swift; sourceTree = ""; }; FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUI.swift; sourceTree = ""; }; - FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMetadata.swift; sourceTree = ""; }; + FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+LibSession.swift"; sourceTree = ""; }; FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMocking.swift; sourceTree = ""; }; FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProRefundingStatus.swift; sourceTree = ""; }; + FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMessageFeatures.swift; sourceTree = ""; }; + FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringProviders.swift; sourceTree = ""; }; FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = ""; }; FD2272542C32911A004D8A6C /* GroupLeavingJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupLeavingJob.swift; sourceTree = ""; }; FD2272552C32911A004D8A6C /* CheckForAppUpdatesJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CheckForAppUpdatesJob.swift; sourceTree = ""; }; @@ -2398,7 +2400,7 @@ FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUIManagerType.swift; sourceTree = ""; }; FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeaturesForMessage.swift; sourceTree = ""; }; FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatureStatus.swift; sourceTree = ""; }; - FDAA36C92EB476060040603E /* SessionProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProFeatures.swift; sourceTree = ""; }; + FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProProfileFeatures.swift; sourceTree = ""; }; FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedProForMessage.swift; sourceTree = ""; }; FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProDecodedStatus.swift; sourceTree = ""; }; FDAB8A842EB2BC2F000A6C65 /* MentionSelectionView+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionSelectionView+SessionMessagingKit.swift"; sourceTree = ""; }; @@ -3614,7 +3616,6 @@ children = ( FD37E9C428A1C701003AE748 /* Themes */, 947AD68F2C8968FF000B2730 /* Constants.swift */, - 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */, B8BB82BD2394D4CE00BA5194 /* Fonts.swift */, FDF848F029406A30007DCAE5 /* Format.swift */, FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */, @@ -4976,6 +4977,7 @@ FD71163028E2C41900B47552 /* Types */ = { isa = PBXGroup; children = ( + FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */, FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, @@ -5317,6 +5319,7 @@ isa = PBXGroup; children = ( FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */, + FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */, ); path = Types; sourceTree = ""; @@ -5337,12 +5340,12 @@ FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, - FDAA36C92EB476060040603E /* SessionProFeatures.swift */, + FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, - FD1F3CF22ED657A800E536D5 /* SessionProMetadata.swift */, + FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */, FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, ); @@ -6770,6 +6773,7 @@ FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */, 942256982C23F8DD00C0FDBF /* SessionTextField.swift in Sources */, 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */, + FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, @@ -6831,7 +6835,6 @@ 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, - 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */, @@ -7208,6 +7211,7 @@ FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, FD22726F2C32911C004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift in Sources */, + FD1F3CFA2ED7B34C00E536D5 /* SessionProMessageFeatures.swift in Sources */, FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, FDF71EA52B07363500A8D6B5 /* MessageReceiver+LibSession.swift in Sources */, @@ -7347,7 +7351,7 @@ FD99A3BA2EC58DE300E59F94 /* _048_SessionProChanges.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, FD245C55285065E500B966DD /* CommunityManager.swift in Sources */, - FDAA36CA2EB476090040603E /* SessionProFeatures.swift in Sources */, + FDAA36CA2EB476090040603E /* SessionProProfileFeatures.swift in Sources */, C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */, FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, @@ -7385,7 +7389,7 @@ FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, - FD1F3CF32ED657AC00E536D5 /* SessionProMetadata.swift in Sources */, + FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */, FDFE75B12ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, FD2273012C352D8E004D8A6C /* LibSession+Shared.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index c242049306..5b35ff8098 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -638,7 +638,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): - let finalFeatures: SessionPro.Features = { + let finalFeatures: SessionPro.ProfileFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } return features @@ -920,7 +920,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// Update the caches with the newly fetched values quoteMap.merge(fetchedQuoteMap, uniquingKeysWith: { _, new in new }) fetchedProfiles.forEach { profile in - let finalFeatures: SessionPro.Features = { + let finalFeatures: SessionPro.ProfileFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } return profile.proFeatures @@ -1282,11 +1282,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // Generate the optimistic data let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages let currentState: State = await self.state - let proFeatures: SessionPro.Features = try { - let userProfileFeatures: SessionPro.Features = (dependencies[singleton: .sessionProManager].currentUserCurrentProFeatures ?? .none) + let proMessageFeatures: SessionPro.MessageFeatures = try { let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features( - for: (text ?? ""), - features: userProfileFeatures + for: (text ?? "") ) switch result.status { @@ -1294,14 +1292,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .utfDecodingError: Log.warn(.messageSender, "Failed to extract features for message, falling back to manual handling") guard (text ?? "").utf16.count > SessionPro.CharacterLimit else { - return userProfileFeatures + return .none } - return userProfileFeatures.union(.largerCharacterLimit) + return .largerCharacterLimit case .exceedsCharacterLimit: throw MessageError.messageTooLarge } }() + let proProfileFeatures: SessionPro.ProfileFeatures = ( + dependencies[singleton: .sessionProManager].currentUserCurrentProProfileFeatures ?? + .none + ) let interaction: Interaction = Interaction( threadId: currentState.threadId, threadVariant: currentState.threadVariant, @@ -1317,7 +1319,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), expiresInSeconds: currentState.threadViewModel.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewViewModel?.urlString, - proFeatures: proFeatures, + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures, using: dependencies ) var optimisticAttachments: [Attachment]? diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 2c12b12fa8..7459a0dbaa 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -144,8 +144,8 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { LinkPreviewView_SwiftUI( viewModel: LinkPreviewViewModel( state: .draft, - urlString: "https://github.com/oxen-io", - title: "Github - oxen-io/session-ios: A private messenger for iOS.", + urlString: "https://github.com/session-foundation", + title: "Github - session-foundation/session-ios: A private messenger for iOS.", imageSource: .image("AppIcon", UIImage(named: "AppIcon")) ), dataManager: ImageDataManager(), @@ -156,7 +156,7 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { LinkPreviewView_SwiftUI( viewModel: LinkPreviewViewModel( state: .loading, - urlString: "https://github.com/oxen-io" + urlString: "https://github.com/session-foundation" ), dataManager: ImageDataManager(), isOutgoing: true diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index df9519bc43..6abad429a0 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -396,7 +396,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { nil ) authorLabel.themeTextColor = .textPrimary - authorLabel.isProBadgeHidden = !cellViewModel.proFeatures.contains(.proBadge) + authorLabel.isProBadgeHidden = !cellViewModel.profile.proFeatures.contains(.proBadge) // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity diff --git a/Session/Home/App Review/AppReviewPromptModel.swift b/Session/Home/App Review/AppReviewPromptModel.swift index 3d214db78c..6130fc4cc7 100644 --- a/Session/Home/App Review/AppReviewPromptModel.swift +++ b/Session/Home/App Review/AppReviewPromptModel.swift @@ -104,7 +104,7 @@ enum AppReviewPromptState { .localized(), message: "rateSessionModalDescription" .put(key: "app_name", value: Constants.app_name) - .put(key: "storevariant", value: SessionPro.Metadata.appStore.store) + .put(key: "storevariant", value: Constants.PaymentProvider.appStore.store) .localized(), primaryButtonTitle: "rateSessionApp".localized(), primaryButtonAccessibilityIdentifier: "rate-app-button", diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 26092f9396..3b458e3e56 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -173,7 +173,7 @@ public class HomeViewModel: NavigatableStateHolder { ) -> State { return State( viewState: .loading, - userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""), + userProfile: Profile.with(id: dependencies[cache: .general].sessionId.hexString, name: ""), serviceNetwork: dependencies[feature: .serviceNetwork], forceOffline: dependencies[feature: .forceOffline], hasSavedThread: false, @@ -299,7 +299,7 @@ public class HomeViewModel: NavigatableStateHolder { case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): - let finalFeatures: SessionPro.Features = { + let finalFeatures: SessionPro.ProfileFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } return features @@ -763,11 +763,11 @@ public class HomeViewModel: NavigatableStateHolder { @MainActor func submitFeedbackSurvery() { - guard let url: URL = URL(string: Constants.session_feedback_url) else { return } + guard let url: URL = URL(string: Constants.urls.feedback) else { return } // stringlint:disable let surveyUrl: URL = url.appending(queryItems: [ - .init(name: "platform", value: SessionPro.Metadata.appStore.device), + .init(name: "platform", value: Constants.PaymentProvider.appStore.device), .init(name: "version", value: dependencies[cache: .appVersion].appVersion) ]) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 5d6e16a22a..ac1a29a26f 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -57,18 +57,21 @@ struct MessageInfoScreen: View { } } - static func from(_ features: SessionPro.Features) -> [ProFeature] { + static func from( + messageFeatures: SessionPro.MessageFeatures, + profileFeatures: SessionPro.ProfileFeatures + ) -> [ProFeature] { var result: [ProFeature] = [] - if features.contains(.proBadge) { + if profileFeatures.contains(.proBadge) { result.append(.proBadge) } - if features.contains(.largerCharacterLimit) { + if messageFeatures.contains(.largerCharacterLimit) { result.append(.increasedMessageLength) } - if features.contains(.animatedAvatar) { + if profileFeatures.contains(.animatedAvatar) { result.append(.animatedDisplayPicture) } @@ -111,7 +114,10 @@ struct MessageInfoScreen: View { profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), using: dependencies ).front, - proFeatures: ProFeature.from(messageViewModel.proFeatures), + proFeatures: ProFeature.from( + messageFeatures: messageViewModel.proMessageFeatures, + profileFeatures: messageViewModel.proProfileFeatures + ), shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge), displayNameRetriever: displayNameRetriever ) @@ -855,15 +861,16 @@ struct MessageInfoView_Previews: PreviewProvider { body: "Mauris sapien dui, sagittis et fringilla eget, tincidunt vel mauris. Mauris bibendum quis ipsum ac pulvinar. Integer semper elit vitae placerat efficitur. Quisque blandit scelerisque orci, a fringilla dui. In a sollicitudin tortor. Vivamus consequat sollicitudin felis, nec pretium dolor bibendum sit amet. Integer non congue risus, id imperdiet diam. Proin elementum enim at felis commodo semper. Pellentesque magna magna, laoreet nec hendrerit in, suscipit sit amet risus. Nulla et imperdiet massa. Donec commodo felis quis arcu dignissim lobortis. Praesent nec fringilla felis, ut pharetra sapien. Donec ac dignissim nisi, non lobortis justo. Nulla congue velit nec sodales bibendum. Nullam feugiat, mauris ac consequat posuere, eros sem dignissim nulla, ac convallis dolor sem rhoncus dolor. Cras ut luctus risus, quis viverra mauris.", timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), state: .failed, - proFeatures: .proBadge, + proMessageFeatures: .largerCharacterLimit, using: dependencies ), reactionInfo: nil, maybeUnresolvedQuotedInfo: nil, profileCache: [ - "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg": Profile( + "d4f1g54sdf5g1d5f4g65ds4564df65f4g65d54gdfsg": Profile.with( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", - name: "TestUser" + name: "TestUser", + proFeatures: .proBadge ) ], attachmentCache: [:], diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 0826ccb467..075a1ef7bb 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -124,14 +124,18 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } - func proUrlStringProvider() -> SessionProUI.UrlStringProvider { - return SessionPro.Metadata.urls + func urlStringProvider() -> StringProvider.Url { + return Constants.urls } - func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + func buildVariantStringProvider() -> StringProvider.BuildVariant { + return Constants.buildVariants + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { switch platform { - case .iOS: return SessionPro.Metadata.appStore - case .android: return SessionPro.Metadata.playStore + case .iOS: return Constants.PaymentProvider.appStore + case .android: return Constants.PaymentProvider.playStore } } } diff --git a/Session/Onboarding/LandingScreen.swift b/Session/Onboarding/LandingScreen.swift index c94d6624e9..3725692cdb 100644 --- a/Session/Onboarding/LandingScreen.swift +++ b/Session/Onboarding/LandingScreen.swift @@ -188,12 +188,12 @@ struct LandingScreen: View { cancelStyle: .textPrimary, hasCloseButton: true, onConfirm: { _ in - if let url: URL = URL(string: "https://getsession.org/terms-of-service") { + if let url: URL = URL(string: Constants.urls.termsOfService) { UIApplication.shared.open(url) } }, onCancel: { modal in - if let url: URL = URL(string: "https://getsession.org/privacy-policy") { + if let url: URL = URL(string: Constants.urls.privacyPolicy) { UIApplication.shared.open(url) } modal.close() diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 5d2494b42c..811cffbe99 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -1251,7 +1251,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, isApproved: true, currentUserSessionId: currentUserSessionId ).upserted(db) - _ = try Profile( + _ = try Profile.with( id: sessionId.hexString, name: String(format: "\(self?.contactPrefix ?? "")%04d", index + 1) ).upserted(db) diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 4b3ddc2932..c0c595a25e 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -80,9 +80,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/translate") else { - return - } + guard let url: URL = URL(string: Constants.urls.translate) else { return } UIApplication.shared.open(url) } @@ -102,9 +100,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/survey") else { - return - } + guard let url: URL = URL(string: Constants.urls.survey) else { return } UIApplication.shared.open(url) } @@ -124,9 +120,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://getsession.org/faq") else { - return - } + guard let url: URL = URL(string: Constants.urls.faq) else { return } UIApplication.shared.open(url) } @@ -146,9 +140,7 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa pinEdges: [.right] ), onTap: { - guard let url: URL = URL(string: "https://sessionapp.zendesk.com/hc/en-us") else { - return - } + guard let url: URL = URL(string: Constants.urls.support) else { return } UIApplication.shared.open(url) } diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 5f88642134..6414e40918 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -582,7 +582,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: info.id, variant: .cell( - info: .init( + info: ListItemCell.Info( leadingAccessory: .icon( info.icon, iconSize: .medium, @@ -591,8 +591,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType backgroundSize: .veryLarge, backgroundCornerRadius: 8 ), - title: .init(info.title, font: .Headings.H9, accessory: info.accessory), - description: .init(info.description, font: .Body.smallRegular, color: .textSecondary) + title: SessionListScreenContent.TextInfo( + info.title, + font: .Headings.H9, + accessory: info.accessory + ), + description: SessionListScreenContent.TextInfo( + info.description, + font: .Body.smallRegular, + color: .textSecondary + ) ) ) ) @@ -600,7 +608,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType SessionListScreenContent.ListItemInfo( id: .plusLoadsMore, variant: .cell( - info: .init( + info: ListItemCell.Info( leadingAccessory: .icon( Lucide.image(icon: .circlePlus, size: IconSize.medium.size), iconSize: .medium, @@ -614,8 +622,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType backgroundSize: .veryLarge, backgroundCornerRadius: 8 ), - title: .init("plusLoadsMore".localized(), font: .Headings.H9), - description: .init( + title: SessionListScreenContent.TextInfo( + "plusLoadsMore".localized(), + font: .Headings.H9 + ), + description: SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "plusLoadsMoreDescription" .put(key: "pro", value: Constants.pro) @@ -625,7 +636,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(SessionPro.Metadata.urls.roadmap) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.proRoadmap) } ) ) ) @@ -666,7 +677,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(Constants.session_pro_faq_url) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.proFaq) } ), SessionListScreenContent.ListItemInfo( id: .support, @@ -694,7 +705,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in viewModel?.openUrl(SessionPro.Metadata.urls.support) } + onTap: { [weak viewModel] in viewModel?.openUrl(Constants.urls.support) } ) ] ) @@ -1116,7 +1127,7 @@ extension SessionProSettingsViewModel { try? await dependencies[singleton: .sessionProManager].refreshProState() } }, - onCancel: { [weak self] _ in self?.openUrl(SessionPro.Metadata.urls.support) } + onCancel: { [weak self] _ in self?.openUrl(Constants.urls.support) } ) ) @@ -1199,7 +1210,7 @@ extension SessionProSettingsViewModel { return modal.dismiss(animated: true) } - self?.openUrl(SessionPro.Metadata.urls.proAccessNotFound) + self?.openUrl(Constants.urls.proAccessNotFound) } ) ) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 9aa857f392..3dcae98c90 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -1052,7 +1052,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } private func openDonationsUrl() { - guard let url: URL = URL(string: Constants.session_donations_url) else { return } + guard let url: URL = URL(string: Constants.urls.donationsApp) else { return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -1087,7 +1087,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } private func openTokenUrl() { - guard let url: URL = URL(string: Constants.session_token_url) else { return } + guard let url: URL = URL(string: Constants.urls.token) else { return } let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index e3ed6f6116..27969be95e 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -110,7 +110,7 @@ enum MockDataGenerator { currentUserSessionId: userSessionId ) .upserted(db) - try Profile( + try Profile.with( id: randomSessionId, name: (0..( plaintext: I, - proFeatures: SessionPro.Features, + proMessageFeatures: SessionPro.MessageFeatures, + proProfileFeatures: SessionPro.ProfileFeatures, destination: Message.Destination, sentTimestampMs: UInt64 ) throws -> Crypto.Generator where R.Element == UInt8 { @@ -21,7 +22,7 @@ public extension Crypto.Generator { let cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey let cRotatingProSecretKey: [UInt8]? = { /// If the message doens't contain any pro features then we shouldn't include a pro signature - guard proFeatures != .none else { return nil } + guard proMessageFeatures != .none || proProfileFeatures != .none else { return nil } return dependencies[singleton: .sessionProManager] .currentUserCurrentRotatingKeyPair? @@ -418,7 +419,7 @@ public extension Crypto.Generator { } } -extension bytes32: CAccessible & CMutable {} -extension bytes33: CAccessible & CMutable {} -extension bytes64: CAccessible & CMutable {} -extension session_protocol_decode_envelope_keys: CAccessible & CMutable {} +extension bytes32: @retroactive CAccessible & CMutable {} +extension bytes33: @retroactive CAccessible & CMutable {} +extension bytes64: @retroactive CAccessible & CMutable {} +extension session_protocol_decode_envelope_keys: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index ee54acb3db..fccfbaace9 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -64,7 +64,7 @@ enum _028_GenerateInitialUserConfigDumps: Migration { displayName: .set(to: (userProfile?["name"] ?? "")), displayPictureUrl: .set(to: userProfile?["profilePictureUrl"]), displayPictureEncryptionKey: .set(to: userProfile?["profileEncryptionKey"]), - proFeatures: .useExisting, + proProfileFeatures: .useExisting, isReuploadProfilePicture: false ) diff --git a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift index e1cef71480..964c913e75 100644 --- a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift @@ -12,7 +12,8 @@ enum _048_SessionProChanges: Migration { static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "interaction") { t in t.drop(column: "isProMessage") - t.add(column: "proFeatures", .integer).defaults(to: 0) + t.add(column: "proMessageFeatures", .integer).defaults(to: 0) + t.add(column: "proProfileFeatures", .integer).defaults(to: 0) } try db.alter(table: "profile") { t in diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 9ebef5f225..8efdee7998 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -52,7 +52,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, case mostRecentFailureText // Session Pro - case proFeatures + case proMessageFeatures + case proProfileFeatures } public enum Variant: Int, Sendable, Codable, Hashable, DatabaseValueConvertible, CaseIterable { @@ -203,8 +204,11 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, /// The reason why the most recent attempt to send this message failed public private(set) var mostRecentFailureText: String? - /// A bitset indicating which Session Pro features were used when this message was sent - public let proFeatures: SessionPro.Features + /// A bitset indicating which Session Pro message features were used when this message was sent + public let proMessageFeatures: SessionPro.MessageFeatures + + /// A bitset indicating which Session Pro profile features were used when this message was sent + public let proProfileFeatures: SessionPro.ProfileFeatures // MARK: - Initialization @@ -230,7 +234,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, state: State, recipientReadTimestampMs: Int64?, mostRecentFailureText: String?, - proFeatures: SessionPro.Features + proMessageFeatures: SessionPro.MessageFeatures, + proProfileFeatures: SessionPro.ProfileFeatures ) { self.id = id self.serverHash = serverHash @@ -253,7 +258,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, self.state = (variant.isLocalOnly ? .localOnly : state) self.recipientReadTimestampMs = recipientReadTimestampMs self.mostRecentFailureText = mostRecentFailureText - self.proFeatures = proFeatures + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } public init( @@ -275,7 +281,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil, state: Interaction.State? = nil, - proFeatures: SessionPro.Features = .none, + proMessageFeatures: SessionPro.MessageFeatures = .none, + proProfileFeatures: SessionPro.ProfileFeatures = .none, using dependencies: Dependencies ) { self.serverHash = serverHash @@ -290,7 +297,7 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, case .standardIncoming, .standardOutgoing: return dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - /// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value + /// For Interactions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value default: return timestampMs } }() @@ -312,7 +319,8 @@ public struct Interaction: Sendable, Codable, Identifiable, Equatable, Hashable, self.recipientReadTimestampMs = nil self.mostRecentFailureText = nil - self.proFeatures = proFeatures + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } // MARK: - Custom Database Interaction @@ -402,7 +410,8 @@ public extension Interaction { state: try container.decode(State.self, forKey: .state), recipientReadTimestampMs: try? container.decode(Int64?.self, forKey: .recipientReadTimestampMs), mostRecentFailureText: try? container.decode(String?.self, forKey: .mostRecentFailureText), - proFeatures: try container.decode(SessionPro.Features.self, forKey: .proFeatures) + proMessageFeatures: try container.decode(SessionPro.MessageFeatures.self, forKey: .proMessageFeatures), + proProfileFeatures: try container.decode(SessionPro.ProfileFeatures.self, forKey: .proProfileFeatures) ) } } @@ -446,7 +455,8 @@ public extension Interaction { state: (state ?? self.state), recipientReadTimestampMs: (recipientReadTimestampMs ?? self.recipientReadTimestampMs), mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), - proFeatures: self.proFeatures + proMessageFeatures: self.proMessageFeatures, + proProfileFeatures: self.proProfileFeatures ) } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 6a24fecf6a..915f512d01 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -54,7 +54,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet public let blocksCommunityMessageRequests: Bool? /// The Session Pro features enabled for this profile - public let proFeatures: SessionPro.Features + public let proFeatures: SessionPro.ProfileFeatures /// The unix timestamp (in milliseconds) when Session Pro expires for this profile public let proExpiryUnixTimestampMs: UInt64 @@ -64,7 +64,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet // MARK: - Initialization - public init( + public static func with( id: String, name: String, nickname: String? = nil, @@ -72,20 +72,22 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet displayPictureEncryptionKey: Data? = nil, profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - proFeatures: SessionPro.Features = .none, + proFeatures: SessionPro.ProfileFeatures = .none, proExpiryUnixTimestampMs: UInt64 = 0, proGenIndexHashHex: String? = nil - ) { - self.id = id - self.name = name - self.nickname = nickname - self.displayPictureUrl = displayPictureUrl - self.displayPictureEncryptionKey = displayPictureEncryptionKey - self.profileLastUpdated = profileLastUpdated - self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.proFeatures = proFeatures - self.proExpiryUnixTimestampMs = proExpiryUnixTimestampMs - self.proGenIndexHashHex = proGenIndexHashHex + ) -> Profile { + return Profile( + id: id, + name: name, + nickname: nickname, + displayPictureUrl: displayPictureUrl, + displayPictureEncryptionKey: displayPictureEncryptionKey, + profileLastUpdated: profileLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests, + proFeatures: proFeatures, + proExpiryUnixTimestampMs: proExpiryUnixTimestampMs, + proGenIndexHashHex: proGenIndexHashHex + ) } } @@ -145,7 +147,10 @@ public extension Profile { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: displayPictureKey, profileLastUpdated: try container.decodeIfPresent(TimeInterval.self, forKey: .profileLastUpdated), - blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests) + blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests), + proFeatures: try container.decode(SessionPro.ProfileFeatures.self, forKey: .proFeatures), + proExpiryUnixTimestampMs: try container.decode(UInt64.self, forKey: .proExpiryUnixTimestampMs), + proGenIndexHashHex: try container.decodeIfPresent(String.self, forKey: .proGenIndexHashHex) ) } @@ -159,6 +164,9 @@ public extension Profile { try container.encodeIfPresent(displayPictureEncryptionKey, forKey: .displayPictureEncryptionKey) try container.encodeIfPresent(profileLastUpdated, forKey: .profileLastUpdated) try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) + try container.encode(proFeatures, forKey: .proFeatures) + try container.encode(proExpiryUnixTimestampMs, forKey: .proExpiryUnixTimestampMs) + try container.encodeIfPresent(proGenIndexHashHex, forKey: .proGenIndexHashHex) } } @@ -452,7 +460,7 @@ public extension Profile { displayPictureEncryptionKey: Update = .useExisting, profileLastUpdated: Update = .useExisting, blocksCommunityMessageRequests: Update = .useExisting, - proFeatures: Update = .useExisting, + proFeatures: Update = .useExisting, proExpiryUnixTimestampMs: Update = .useExisting, proGenIndexHashHex: Update = .useExisting ) -> Profile { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 53ee042c0c..c46b00ded5 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -79,7 +79,7 @@ internal extension LibSessionCacheType { return .contactUpdate( Profile.ProState( - features: data.profile.proFeatures, + profileFeatures: data.profile.proFeatures, expiryUnixTimestampMs: data.profile.proExpiryUnixTimestampMs, genIndexHashHex: genIndexHashHex ) @@ -714,7 +714,7 @@ extension LibSession { fileprivate var profile: Profile? { guard let name: String = name else { return nil } - return Profile( + return Profile.with( id: id, name: name, nickname: nickname, @@ -847,7 +847,8 @@ internal extension LibSessionCacheType { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), profileLastUpdated: TimeInterval(contact.profile_updated), - proFeatures: SessionPro.Features(contact.pro_features), + blocksCommunityMessageRequests: nil, /// Not synced + proFeatures: SessionPro.ProfileFeatures(contact.profile_bitset), proExpiryUnixTimestampMs: (proProofMetadata?.expiryUnixTimestampMs ?? 0), proGenIndexHashHex: proProofMetadata?.genIndexHashHex ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 6bfaec7868..74bc5f673c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -556,7 +556,7 @@ internal extension LibSession { } result.append( - Profile( + Profile.with( id: member.get(\.session_id), name: member.get(\.name), nickname: nil, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index f735449090..2a38ae77a7 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -762,7 +762,7 @@ public extension LibSession.Cache { visibleMessage?.profile?.displayName?.nullIfEmpty ) let profileLastUpdatedInMessage: TimeInterval? = visibleMessage?.profile?.updateTimestampSeconds - let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } + let fallbackProfile: Profile? = displayNameInMessage.map { Profile.with(id: contactId, name: $0) } guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { return fallbackProfile @@ -781,7 +781,7 @@ public extension LibSession.Cache { let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(user_profile_get_profile_updated(conf))) let proConfig: SessionPro.ProConfig? = self.proConfig - let proFeatures: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) + let proProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) return Profile( id: contactId, @@ -790,7 +790,8 @@ public extension LibSession.Cache { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), - proFeatures: proFeatures, + blocksCommunityMessageRequests: !self.get(.checkForCommunityMessageRequests), + proFeatures: proProfileFeatures, proExpiryUnixTimestampMs: (proConfig?.proProof.expiryUnixTimestampMs ?? 0), proGenIndexHashHex: proConfig.map { $0.proProof.genIndexHash.toHexString() } ) @@ -823,8 +824,9 @@ public extension LibSession.Cache { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), + blocksCommunityMessageRequests: visibleMessage?.profile?.blocksCommunityMessageRequests, /// Group members don't sync pro status so try to extract from the provided message - proFeatures: (visibleMessage?.proFeatures ?? .none), + proFeatures: (visibleMessage?.proProfileFeatures ?? .none), proExpiryUnixTimestampMs: (visibleMessage?.proProof?.expiryUnixTimestampMs ?? 0), proGenIndexHashHex: visibleMessage?.proProof.map { $0.genIndexHash.toHexString() } ) @@ -844,7 +846,7 @@ public extension LibSession.Cache { let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(contact.get( \.profile_updated))) - let proFeatures: SessionPro.Features = SessionPro.Features(contact.pro_features) + let proProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(contact.profile_bitset) let proProofMetadata: LibSession.ProProofMetadata? = proProofMetadata(threadId: contactId) /// The `displayNameInMessage` and other values contained within the message are likely newer than the values stored @@ -856,7 +858,8 @@ public extension LibSession.Cache { displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil), - proFeatures: (visibleMessage?.proFeatures ?? proFeatures), + blocksCommunityMessageRequests: visibleMessage?.profile?.blocksCommunityMessageRequests, + proFeatures: (visibleMessage?.proProfileFeatures ?? proProfileFeatures), proExpiryUnixTimestampMs: ( visibleMessage?.proProof?.expiryUnixTimestampMs ?? proProofMetadata?.expiryUnixTimestampMs ?? diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 97960c5b9e..266082fce9 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -69,11 +69,11 @@ internal extension LibSessionCacheType { ) else { return .none } - let features: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) + let profileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) return .currentUserUpdate( Profile.ProState( - features: features, + profileFeatures: profileFeatures, expiryUnixTimestampMs: proConfig.proProof.expiryUnixTimestampMs, genIndexHashHex: proConfig.proProof.genIndexHash.toHexString() ) @@ -261,7 +261,7 @@ public extension LibSession.Cache { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, - proFeatures: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { @@ -277,7 +277,7 @@ public extension LibSession.Cache { let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, nullIfEmpty: true) - let oldProFeatures: SessionPro.Features = SessionPro.Features(user_profile_get_pro_features(conf)) + let oldProProfileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) /// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) /// @@ -314,9 +314,9 @@ public extension LibSession.Cache { /// /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was /// changed (this will be fixed in `libSession v1.5.8`) - if proFeatures.or(.none) != oldProFeatures { - user_profile_set_pro_badge(conf, proFeatures.or(.none).contains(.proBadge)) - user_profile_set_animated_avatar(conf, proFeatures.or(.none).contains(.animatedAvatar)) + if proProfileFeatures.or(.none) != oldProProfileFeatures { + user_profile_set_pro_badge(conf, proProfileFeatures.or(.none).contains(.proBadge)) + user_profile_set_animated_avatar(conf, proProfileFeatures.or(.none).contains(.animatedAvatar)) } } @@ -346,4 +346,4 @@ public extension LibSession { // MARK: - C Conformance -extension user_profile_pic: CAccessible & CMutable {} +extension user_profile_pic: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 3baeef6da8..c91398f361 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1064,7 +1064,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, - proFeatures: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws func updateProConfig(proConfig: SessionPro.ProConfig) @@ -1214,7 +1214,7 @@ public extension LibSessionCacheType { displayName: .set(to: displayName), displayPictureUrl: .useExisting, displayPictureEncryptionKey: .useExisting, - proFeatures: .useExisting, + proProfileFeatures: .useExisting, isReuploadProfilePicture: false ) } @@ -1348,7 +1348,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, - proFeatures: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws {} func updateProConfig(proConfig: SessionPro.ProConfig) {} diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 2eb573cf52..e18234cfd2 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -21,7 +21,8 @@ public class Message: Codable { case expiresInSeconds case expiresStartedAtMs - case proFeatures + case proMessageFeatures + case proProfileFeatures case proProof } @@ -47,7 +48,8 @@ public class Message: Codable { public var expiresStartedAtMs: Double? public var proProof: Network.SessionPro.ProProof? - public var proFeatures: SessionPro.Features? + public var proMessageFeatures: SessionPro.MessageFeatures? + public var proProfileFeatures: SessionPro.ProfileFeatures? // MARK: - Validation @@ -110,7 +112,8 @@ public class Message: Codable { expiresInSeconds: TimeInterval? = nil, expiresStartedAtMs: Double? = nil, proProof: Network.SessionPro.ProProof? = nil, - proFeatures: SessionPro.Features? = nil + proMessageFeatures: SessionPro.MessageFeatures? = nil, + proProfileFeatures: SessionPro.ProfileFeatures? = nil ) { self.id = id self.sentTimestampMs = sentTimestampMs @@ -124,7 +127,8 @@ public class Message: Codable { self.expiresInSeconds = expiresInSeconds self.expiresStartedAtMs = expiresStartedAtMs self.proProof = proProof - self.proFeatures = proFeatures + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures } // MARK: - Proto Conversion diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 1c956b29ed..3a439535e8 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -62,7 +62,8 @@ public final class VisibleMessage: Message { openGroupInvitation: VMOpenGroupInvitation? = nil, reaction: VMReaction? = nil, proProof: Network.SessionPro.ProProof? = nil, - proFeatures: SessionPro.Features? = nil + proMessageFeatures: SessionPro.MessageFeatures? = nil, + proProfileFeatures: SessionPro.ProfileFeatures? = nil ) { self.syncTarget = syncTarget self.text = text @@ -78,7 +79,8 @@ public final class VisibleMessage: Message { sentTimestampMs: sentTimestampMs, sender: sender, proProof: proProof, - proFeatures: proFeatures + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures ) } @@ -121,8 +123,13 @@ public final class VisibleMessage: Message { public override class func fromProto(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) -> VisibleMessage? { guard let dataMessage = proto.dataMessage else { return nil } - let proInfo: (proof: Network.SessionPro.ProProof, features: SessionPro.Features)? = proto.proMessage - .map { proMessage -> (proof: Network.SessionPro.ProProof, features: SessionPro.Features)? in + typealias ProInfo = ( + proof: Network.SessionPro.ProProof, + messageFeatures: SessionPro.MessageFeatures, + profileFeatures: SessionPro.ProfileFeatures + ) + let proInfo: ProInfo? = proto.proMessage + .map { proMessage -> ProInfo? in guard let vmProof: SNProtoProProof = proMessage.proof, vmProof.hasVersion, @@ -141,7 +148,8 @@ public final class VisibleMessage: Message { expiryUnixTimestampMs: vmProof.expiryUnixTs, signature: Array(vmSig) ), - SessionPro.Features(proMessage.features) + SessionPro.MessageFeatures(rawValue: proMessage.msgBitset), + SessionPro.ProfileFeatures(rawValue: proMessage.profileBitset) ) } @@ -156,7 +164,8 @@ public final class VisibleMessage: Message { openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) }, reaction: dataMessage.reaction.map { VMReaction.fromProto($0) }, proProof: proInfo?.proof, - proFeatures: proInfo?.features + proMessageFeatures: proInfo?.messageFeatures, + proProfileFeatures: proInfo?.profileFeatures ) } @@ -214,10 +223,14 @@ public final class VisibleMessage: Message { } // Pro content + let proMessageFeatures: SessionPro.MessageFeatures = (self.proMessageFeatures ?? .none) + let proProfileFeatures: SessionPro.ProfileFeatures = (self.proProfileFeatures ?? .none) + if - let proProof: Network.SessionPro.ProProof = proProof, - let proFeatures: SessionPro.Features = proFeatures, - proFeatures != .none + let proProof: Network.SessionPro.ProProof = proProof, ( + proMessageFeatures != .none || + proProfileFeatures != .none + ) { let proMessageBuilder: SNProtoProMessage.SNProtoProMessageBuilder = SNProtoProMessage.builder() let proofBuilder: SNProtoProProof.SNProtoProProofBuilder = SNProtoProProof.builder() @@ -229,7 +242,14 @@ public final class VisibleMessage: Message { do { proMessageBuilder.setProof(try proofBuilder.build()) - proMessageBuilder.setFeatures(proFeatures.rawValue) + + if proMessageFeatures != .none { + proMessageBuilder.setMsgBitset(proMessageFeatures.rawValue) + } + + if proProfileFeatures != .none { + proMessageBuilder.setProfileBitset(proProfileFeatures.rawValue) + } proto.setProMessage(try proMessageBuilder.build()) } catch { diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index d90f9381d5..d0b9da075a 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -4232,8 +4232,11 @@ extension SNProtoProProof.SNProtoProProofBuilder { if let _value = proof { builder.setProof(_value) } - if hasFeatures { - builder.setFeatures(features) + if hasProfileBitset { + builder.setProfileBitset(profileBitset) + } + if hasMsgBitset { + builder.setMsgBitset(msgBitset) } return builder } @@ -4248,8 +4251,12 @@ extension SNProtoProProof.SNProtoProProofBuilder { proto.proof = valueParam.proto } - @objc public func setFeatures(_ valueParam: UInt64) { - proto.features = valueParam + @objc public func setProfileBitset(_ valueParam: UInt64) { + proto.profileBitset = valueParam + } + + @objc public func setMsgBitset(_ valueParam: UInt64) { + proto.msgBitset = valueParam } @objc public func build() throws -> SNProtoProMessage { @@ -4265,11 +4272,18 @@ extension SNProtoProProof.SNProtoProProofBuilder { @objc public let proof: SNProtoProProof? - @objc public var features: UInt64 { - return proto.features + @objc public var profileBitset: UInt64 { + return proto.profileBitset + } + @objc public var hasProfileBitset: Bool { + return proto.hasProfileBitset + } + + @objc public var msgBitset: UInt64 { + return proto.msgBitset } - @objc public var hasFeatures: Bool { - return proto.hasFeatures + @objc public var hasMsgBitset: Bool { + return proto.hasMsgBitset } private init(proto: SessionProtos_ProMessage, diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 709fb33585..e1875ae0f0 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -1859,21 +1859,31 @@ struct SessionProtos_ProMessage { /// Clears the value of `proof`. Subsequent reads from it will return its default value. mutating func clearProof() {self._proof = nil} - var features: UInt64 { - get {return _features ?? 0} - set {_features = newValue} + var profileBitset: UInt64 { + get {return _profileBitset ?? 0} + set {_profileBitset = newValue} } - /// Returns true if `features` has been explicitly set. - var hasFeatures: Bool {return self._features != nil} - /// Clears the value of `features`. Subsequent reads from it will return its default value. - mutating func clearFeatures() {self._features = nil} + /// Returns true if `profileBitset` has been explicitly set. + var hasProfileBitset: Bool {return self._profileBitset != nil} + /// Clears the value of `profileBitset`. Subsequent reads from it will return its default value. + mutating func clearProfileBitset() {self._profileBitset = nil} + + var msgBitset: UInt64 { + get {return _msgBitset ?? 0} + set {_msgBitset = newValue} + } + /// Returns true if `msgBitset` has been explicitly set. + var hasMsgBitset: Bool {return self._msgBitset != nil} + /// Clears the value of `msgBitset`. Subsequent reads from it will return its default value. + mutating func clearMsgBitset() {self._msgBitset = nil} var unknownFields = SwiftProtobuf.UnknownStorage() init() {} fileprivate var _proof: SessionProtos_ProProof? = nil - fileprivate var _features: UInt64? = nil + fileprivate var _profileBitset: UInt64? = nil + fileprivate var _msgBitset: UInt64? = nil } #if swift(>=5.5) && canImport(_Concurrency) @@ -3779,7 +3789,8 @@ extension SessionProtos_ProMessage: SwiftProtobuf.Message, SwiftProtobuf._Messag static let protoMessageName: String = _protobuf_package + ".ProMessage" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "proof"), - 2: .same(proto: "features"), + 2: .same(proto: "profileBitset"), + 3: .same(proto: "msgBitset"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -3789,7 +3800,8 @@ extension SessionProtos_ProMessage: SwiftProtobuf.Message, SwiftProtobuf._Messag // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularMessageField(value: &self._proof) }() - case 2: try { try decoder.decodeSingularUInt64Field(value: &self._features) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self._profileBitset) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self._msgBitset) }() default: break } } @@ -3803,15 +3815,19 @@ extension SessionProtos_ProMessage: SwiftProtobuf.Message, SwiftProtobuf._Messag try { if let v = self._proof { try visitor.visitSingularMessageField(value: v, fieldNumber: 1) } }() - try { if let v = self._features { + try { if let v = self._profileBitset { try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2) } }() + try { if let v = self._msgBitset { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: SessionProtos_ProMessage, rhs: SessionProtos_ProMessage) -> Bool { if lhs._proof != rhs._proof {return false} - if lhs._features != rhs._features {return false} + if lhs._profileBitset != rhs._profileBitset {return false} + if lhs._msgBitset != rhs._msgBitset {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index cd54516ba3..8147a51ef8 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -377,6 +377,7 @@ message ProProof { } message ProMessage { - optional ProProof proof = 1; - optional uint64 features = 2; + optional ProProof proof = 1; + optional uint64 profileBitset = 2; + optional uint64 msgBitset = 3; } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 8fbba8edc1..cb63fb3246 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -630,7 +630,7 @@ extension MessageReceiver { groupSessionIdHexString: groupSessionId.hexString, profile: message.profile.map { profile in profile.displayName.map { - Profile( + Profile.with( id: decodedMessage.sender.hexString, name: $0, displayPictureUrl: profile.profilePictureUrl, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 405a46810f..8394ccc49c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -213,7 +213,8 @@ extension MessageReceiver { // If we received an outgoing message then we can assume the interaction has already // been sent, otherwise we should just use whatever the default state is state: (variant == .standardOutgoing ? .sent : nil), - proFeatures: (message.proFeatures ?? .none), + proMessageFeatures: (message.proMessageFeatures ?? .none), + proProfileFeatures: (message.proProfileFeatures ?? .none), using: dependencies ).inserted(db) } @@ -720,7 +721,7 @@ extension MessageReceiver { /// Extract the features used for the message let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features(for: text) - let proStatus: SessionPro.DecodedStatus = dependencies[singleton: .sessionProManager].proStatus( + let proStatus: SessionPro.DecodedStatus? = dependencies[singleton: .sessionProManager].proStatus( for: decodedMessage.decodedPro?.proProof, verifyPubkey: { switch threadVariant { diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index ce12ed17c8..2613c9bf17 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -90,7 +90,8 @@ public enum MessageReceiver { switch decodedMessage.decodedPro?.status { case .valid, .expired: break case .none, .invalidProBackendSig, .invalidUserSig: - message.proFeatures = nil + message.proMessageFeatures = nil + message.proProfileFeatures = nil message.proProof = nil } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 92bdcc722c..b86450ce71 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -391,7 +391,8 @@ public final class MessageSender { return try dependencies[singleton: .crypto].tryGenerate( .encodedMessage( plaintext: Array(plaintext), - proFeatures: (finalMessage.proFeatures ?? .none), + proMessageFeatures: (finalMessage.proMessageFeatures ?? .none), + proProfileFeatures: (finalMessage.proProfileFeatures ?? .none), destination: destination, sentTimestampMs: sentTimestampMs ) diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 489c9b501c..95a75623a8 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -17,7 +17,7 @@ public struct QuotedReplyModel: Sendable, Equatable, Hashable { public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool - public let proFeatures: SessionPro.Features + public let proMessageFeatures: SessionPro.MessageFeatures public let currentUserSessionIds: Set // MARK: - Initialization @@ -33,7 +33,7 @@ public struct QuotedReplyModel: Sendable, Equatable, Hashable { contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool, - proFeatures: SessionPro.Features, + proMessageFeatures: SessionPro.MessageFeatures, currentUserSessionIds: Set ) { self.threadId = threadId @@ -46,7 +46,7 @@ public struct QuotedReplyModel: Sendable, Equatable, Hashable { self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed - self.proFeatures = proFeatures + self.proMessageFeatures = proMessageFeatures self.currentUserSessionIds = currentUserSessionIds } @@ -60,7 +60,7 @@ public struct QuotedReplyModel: Sendable, Equatable, Hashable { timestampMs: Int64, attachments: [Attachment]?, linkPreviewAttachment: Attachment?, - proFeatures: SessionPro.Features, + proMessageFeatures: SessionPro.MessageFeatures, currentUserSessionIds: Set ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } @@ -79,7 +79,7 @@ public struct QuotedReplyModel: Sendable, Equatable, Hashable { contentType: targetAttachment?.contentType, sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, - proFeatures: proFeatures, + proMessageFeatures: proMessageFeatures, currentUserSessionIds: currentUserSessionIds ) } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 24b7673df2..6c80f0fdab 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -47,7 +47,7 @@ public actor SessionProManager: SessionProManagerType { syncState.proStatus } nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } - nonisolated public var currentUserCurrentProFeatures: SessionPro.Features? { syncState.proFeatures } + nonisolated public var currentUserCurrentProProfileFeatures: SessionPro.ProfileFeatures? { syncState.proProfileFeatures } nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.proStatus == .active } nonisolated public var pinnedConversationLimit: Int { SessionPro.PinnedConversationLimit } @@ -110,8 +110,8 @@ public actor SessionProManager: SessionProManagerType { for proof: Network.SessionPro.ProProof?, verifyPubkey: I?, atTimestampMs timestampMs: UInt64 - ) -> SessionPro.DecodedStatus { - guard let proof: Network.SessionPro.ProProof else { return .none } + ) -> SessionPro.DecodedStatus? { + guard let proof: Network.SessionPro.ProProof else { return nil } var cProProof: session_protocol_pro_proof = proof.libSessionValue let cVerifyPubkey: [UInt8] = (verifyPubkey.map { Array($0) } ?? []) @@ -138,7 +138,7 @@ public actor SessionProManager: SessionProManagerType { return session_protocol_pro_proof_is_active(&cProProof, timestampMs) } - nonisolated public func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage { + nonisolated public func features(for message: String) -> SessionPro.FeaturesForMessage { guard let cMessage: [CChar] = message.cString(using: .utf8) else { return SessionPro.FeaturesForMessage.invalidString } @@ -146,22 +146,23 @@ public actor SessionProManager: SessionProManagerType { return SessionPro.FeaturesForMessage( session_protocol_pro_features_for_utf8( cMessage, - (cMessage.count - 1), /// Need to `- 1` to avoid counting the null-termination character - features.libSessionValue + (cMessage.count - 1) /// Need to `- 1` to avoid counting the null-termination character ) ) } nonisolated public func attachProInfoIfNeeded(message: Message) -> Message { let featuresForMessage: SessionPro.FeaturesForMessage = features( - for: ((message as? VisibleMessage)?.text ?? ""), - features: (syncState.proFeatures ?? .none) + for: ((message as? VisibleMessage)?.text ?? "") ) + let profileFeatures: SessionPro.ProfileFeatures = (syncState.proProfileFeatures ?? .none) /// We only want to attach the `proFeatures` and `proProof` if a pro feature is _actually_ used guard - featuresForMessage.status == .success, - featuresForMessage.features != .none, + featuresForMessage.status == .success, ( + profileFeatures != .none || + featuresForMessage.features != .none + ), let proof: Network.SessionPro.ProProof = syncState.proProof else { if featuresForMessage.status != .success { @@ -171,7 +172,8 @@ public actor SessionProManager: SessionProManagerType { } let updatedMessage: Message = message - updatedMessage.proFeatures = featuresForMessage.features + updatedMessage.proMessageFeatures = featuresForMessage.features + updatedMessage.proProfileFeatures = profileFeatures updatedMessage.proProof = proof return updatedMessage @@ -229,7 +231,7 @@ public actor SessionProManager: SessionProManagerType { rotatingKeyPair: .set(to: rotatingKeyPair), proStatus: .set(to: mockedIfNeeded(proStatus)), proProof: .set(to: proState.proConfig?.proProof), - proFeatures: .set(to: proState.profile.proFeatures) + proProfileFeatures: .set(to: proState.profile.proFeatures) ) /// Then update the async state and streams @@ -608,13 +610,13 @@ private final class SessionProManagerSyncState { private var _rotatingKeyPair: KeyPair? = nil private var _proStatus: Network.SessionPro.BackendUserProStatus? = nil private var _proProof: Network.SessionPro.ProProof? = nil - private var _proFeatures: SessionPro.Features = .none + private var _proProfileFeatures: SessionPro.ProfileFeatures = .none fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } fileprivate var proStatus: Network.SessionPro.BackendUserProStatus? { lock.withLock { _proStatus } } fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } - fileprivate var proFeatures: SessionPro.Features? { lock.withLock { _proFeatures } } + fileprivate var proProfileFeatures: SessionPro.ProfileFeatures? { lock.withLock { _proProfileFeatures } } fileprivate init(using dependencies: Dependencies) { self._dependencies = dependencies @@ -624,13 +626,13 @@ private final class SessionProManagerSyncState { rotatingKeyPair: Update = .useExisting, proStatus: Update = .useExisting, proProof: Update = .useExisting, - proFeatures: Update = .useExisting + proProfileFeatures: Update = .useExisting ) { lock.withLock { self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) self._proStatus = proStatus.or(self._proStatus) self._proProof = proProof.or(self._proProof) - self._proFeatures = proFeatures.or(self._proFeatures) + self._proProfileFeatures = proProfileFeatures.or(self._proProfileFeatures) } } } @@ -644,7 +646,7 @@ public protocol SessionProManagerType: SessionProUIManagerType { nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } nonisolated var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } - nonisolated var currentUserCurrentProFeatures: SessionPro.Features? { get } + nonisolated var currentUserCurrentProProfileFeatures: SessionPro.ProfileFeatures? { get } nonisolated var loadingState: AsyncStream { get } nonisolated var proStatus: AsyncStream { get } @@ -658,12 +660,12 @@ public protocol SessionProManagerType: SessionProUIManagerType { for proof: Network.SessionPro.ProProof?, verifyPubkey: I?, atTimestampMs timestampMs: UInt64 - ) -> SessionPro.DecodedStatus + ) -> SessionPro.DecodedStatus? nonisolated func proProofIsActive( for proof: Network.SessionPro.ProProof?, atTimestampMs timestampMs: UInt64 ) -> Bool - nonisolated func features(for message: String, features: SessionPro.Features) -> SessionPro.FeaturesForMessage + nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage nonisolated func attachProInfoIfNeeded(message: Message) -> Message func updateWithLatestFromUserConfig() async @@ -672,12 +674,6 @@ public protocol SessionProManagerType: SessionProUIManagerType { func requestRefund(scene: UIWindowScene) async throws } -public extension SessionProManagerType { - nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage { - return features(for: message, features: .none) - } -} - // MARK: - Convenience extension SessionProUI.ClientPlatform { @@ -777,7 +773,7 @@ private extension SessionProManager { rotatingKeyPair: .set(to: nil), proStatus: .set(to: nil), proProof: .set(to: nil), - proFeatures: .set(to: .none) + proProfileFeatures: .set(to: .none) ) await self?.loadingStateStream.send(.loading) diff --git a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift index d9fee2789e..88af8ee627 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProDecodedProForMessage.swift @@ -6,22 +6,30 @@ import SessionNetworkingKit public extension SessionPro { struct DecodedProForMessage: Sendable, Codable, Equatable { - let status: SessionPro.DecodedStatus + let status: SessionPro.DecodedStatus? let proProof: Network.SessionPro.ProProof - let features: Features + let messageFeatures: MessageFeatures + let profileFeatures: ProfileFeatures // MARK: - Initialization - init(status: SessionPro.DecodedStatus, proProof: Network.SessionPro.ProProof, features: Features) { + init( + status: SessionPro.DecodedStatus?, + proProof: Network.SessionPro.ProProof, + messageFeatures: MessageFeatures, + profileFeatures: ProfileFeatures + ) { self.status = status self.proProof = proProof - self.features = features + self.messageFeatures = messageFeatures + self.profileFeatures = profileFeatures } init(_ libSessionValue: session_protocol_decoded_pro) { status = SessionPro.DecodedStatus(libSessionValue.status) proProof = Network.SessionPro.ProProof(libSessionValue.proof) - features = Features(libSessionValue.features) + messageFeatures = MessageFeatures(libSessionValue.msg_bitset) + profileFeatures = ProfileFeatures(libSessionValue.profile_bitset) } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift deleted file mode 100644 index c54e983a9f..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeatures.swift +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUtil - -public extension SessionPro { - struct Features: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { - public let rawValue: UInt64 - - public static let none: Features = Features(rawValue: 0) - public static let largerCharacterLimit: Features = Features(rawValue: 1 << 0) - public static let proBadge: Features = Features(rawValue: 1 << 1) - public static let animatedAvatar: Features = Features(rawValue: 1 << 2) - public static let all: Features = [ largerCharacterLimit, proBadge, animatedAvatar ] - - var libSessionValue: SESSION_PROTOCOL_PRO_FEATURES { - SESSION_PROTOCOL_PRO_FEATURES(rawValue) - } - - var profileOnlyFeatures: Features { - self.subtracting(.largerCharacterLimit) - } - - // MARK: - Initialization - - public init(rawValue: UInt64) { - self.rawValue = rawValue - } - - public init(_ libSessionValue: SESSION_PROTOCOL_PRO_FEATURES) { - self = Features(rawValue: libSessionValue) - } - - // MARK: - CustomStringConvertible - - // stringlint:ignore_contents - public var description: String { - var results: [String] = [] - - if self.contains(.largerCharacterLimit) { - results.append("largerCharacterLimit") - } - if self.contains(.proBadge) { - results.append("proBadge") - } - if self.contains(.animatedAvatar) { - results.append("animatedAvatar") - } - - return "[\(results.joined(separator: ", "))]" - } - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift index 06112cb98f..e7449ff5d6 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProFeaturesForMessage.swift @@ -8,14 +8,14 @@ public extension SessionPro { struct FeaturesForMessage: Equatable { public let status: FeatureStatus public let error: String? - public let features: Features + public let features: MessageFeatures public let codePointCount: Int static let invalidString: FeaturesForMessage = FeaturesForMessage(status: .utfDecodingError) // MARK: - Initialization - init(status: FeatureStatus, error: String? = nil, features: Features = [], codePointCount: Int = 0) { + init(status: FeatureStatus, error: String? = nil, features: MessageFeatures = [], codePointCount: Int = 0) { self.status = status self.error = error self.features = features @@ -25,7 +25,7 @@ public extension SessionPro { init(_ libSessionValue: session_protocol_pro_features_for_msg) { status = FeatureStatus(libSessionValue.status) error = libSessionValue.get(\.error, nullIfEmpty: true) - features = Features(libSessionValue.features) + features = MessageFeatures(libSessionValue.bitset) codePointCount = libSessionValue.codepoint_count } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift new file mode 100644 index 0000000000..f9d8873947 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct MessageFeatures: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { + public let rawValue: UInt64 + + public static let none: MessageFeatures = MessageFeatures(rawValue: 0) + public static let largerCharacterLimit: MessageFeatures = MessageFeatures(rawValue: 1 << 0) + public static let all: MessageFeatures = [ largerCharacterLimit ] + + var libSessionValue: session_protocol_pro_message_bitset { + var result: session_protocol_pro_message_bitset = session_protocol_pro_message_bitset() + result.data = rawValue + + return result + } + + var profileOnlyFeatures: MessageFeatures { + self.subtracting(.largerCharacterLimit) + } + + // MARK: - Initialization + // TODO: [PRO] Might be good to actually test what happens if you put an unsupported value in here? (ie. does it get stripped when converting/storing?) + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public init(_ libSessionValue: session_protocol_pro_message_bitset) { + self = MessageFeatures(rawValue: libSessionValue.data) + } + + // MARK: - CustomStringConvertible + + // stringlint:ignore_contents + public var description: String { + var results: [String] = [] + + if self.contains(.largerCharacterLimit) { + results.append("largerCharacterLimit") + } + + return "[\(results.joined(separator: ", "))]" + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift b/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift deleted file mode 100644 index 71433d3a62..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProMetadata.swift +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import SessionUIKit -import SessionUtil -import SessionUtilitiesKit - -public extension SessionPro { - enum Metadata { - private static let providerMetadata: [session_pro_backend_payment_provider_metadata] = [ - SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.0, /// Empty - SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.1, /// Google - SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.2 /// Apple - ] - - public static let urls: GeneralUrls = GeneralUrls(SESSION_PRO_URLS) - public static let appStore: PaymentProvider = PaymentProvider(providerMetadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE.rawValue)]) - public static let playStore: PaymentProvider = PaymentProvider(providerMetadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE.rawValue)]) - } -} - -public extension SessionPro.Metadata { - struct GeneralUrls: SessionProUI.UrlStringProvider { - public let roadmap: String - public let privacyPolicy: String - public let termsOfService: String - public let proAccessNotFound: String - public let support: String - - fileprivate init(_ libSessionValue: session_pro_urls) { - self.roadmap = libSessionValue.get(\.roadmap) - self.privacyPolicy = libSessionValue.get(\.privacy_policy) - self.termsOfService = libSessionValue.get(\.terms_of_service) - self.proAccessNotFound = libSessionValue.get(\.pro_access_not_found) - self.support = libSessionValue.get(\.support_url) - } - } - - struct PaymentProvider: SessionProUI.ClientPlatformStringProvider { - public let device: String - public let store: String - public let platform: String - public let platformAccount: String - public let refundPlatformUrl: String - - /// Some platforms disallow a refund via their native support channels after some time period - /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers - /// themselves). If a platform does not have this restriction, this URL is typically the same as - /// the `refund_platform_url`. - public let refundSupportUrl: String - - public let refundStatusUrl: String - public let updateSubscriptionUrl: String - public let cancelSubscriptionUrl: String - - fileprivate init(_ libSessionValue: session_pro_backend_payment_provider_metadata) { - self.device = libSessionValue.get(\.device) - self.store = libSessionValue.get(\.store) - self.platform = libSessionValue.get(\.platform) - self.platformAccount = libSessionValue.get(\.platform_account) - self.refundPlatformUrl = libSessionValue.get(\.refund_platform_url) - - self.refundSupportUrl = libSessionValue.get(\.refund_support_url) - - self.refundStatusUrl = libSessionValue.get(\.refund_status_url) - self.updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) - self.cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) - } - } -} - -extension session_pro_urls: @retroactive CAccessible {} -extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift new file mode 100644 index 0000000000..b20cc1c377 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProProfileFeatures.swift @@ -0,0 +1,48 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtil + +public extension SessionPro { + struct ProfileFeatures: OptionSet, Sendable, Codable, Equatable, Hashable, CustomStringConvertible { + public let rawValue: UInt64 + + public static let none: ProfileFeatures = ProfileFeatures(rawValue: 0) + public static let proBadge: ProfileFeatures = ProfileFeatures(rawValue: 1 << 0) + public static let animatedAvatar: ProfileFeatures = ProfileFeatures(rawValue: 1 << 1) + public static let all: ProfileFeatures = [ proBadge, animatedAvatar ] + + var libSessionValue: session_protocol_pro_profile_bitset { + var result: session_protocol_pro_profile_bitset = session_protocol_pro_profile_bitset() + result.data = rawValue + + return result + } + + // MARK: - Initialization + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public init(_ libSessionValue: session_protocol_pro_profile_bitset) { + self = ProfileFeatures(rawValue: libSessionValue.data) + } + + // MARK: - CustomStringConvertible + + // stringlint:ignore_contents + public var description: String { + var results: [String] = [] + + if self.contains(.proBadge) { + results.append("proBadge") + } + if self.contains(.animatedAvatar) { + results.append("animatedAvatar") + } + + return "[\(results.joined(separator: ", "))]" + } + } +} diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift index ca243f7384..14dd489788 100644 --- a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift +++ b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift @@ -95,8 +95,8 @@ public extension FeatureStorage { extension SessionProUI.ClientPlatform: @retroactive CustomStringConvertible { public var description: String { switch self { - case .iOS: return SessionPro.Metadata.appStore.device - case .android: return SessionPro.Metadata.playStore.device + case .iOS: return Constants.PaymentProvider.appStore.device + case .android: return Constants.PaymentProvider.playStore.device } } } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index df0a2a89b2..ba0d76d351 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -66,7 +66,8 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif public let quoteViewModel: QuoteViewModel? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? - public let proFeatures: SessionPro.Features + public let proMessageFeatures: SessionPro.MessageFeatures + public let proProfileFeatures: SessionPro.ProfileFeatures public let state: Interaction.State public let hasBeenReadByRecipient: Bool @@ -158,10 +159,11 @@ public extension MessageViewModel { self.expiresInSeconds = nil self.attachments = [] self.reactionInfo = [] - self.profile = Profile(id: "", name: "") + self.profile = Profile.with(id: "", name: "") self.linkPreview = nil self.linkPreviewAttachment = nil - self.proFeatures = .none + self.proMessageFeatures = .none + self.proProfileFeatures = .none self.state = .localOnly self.hasBeenReadByRecipient = false @@ -260,12 +262,17 @@ public extension MessageViewModel { linkPreview: linkPreviewInfo?.preview, using: dependencies ) - let proFeatures: SessionPro.Features = { + let proMessageFeatures: SessionPro.MessageFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } - return interaction.proFeatures - .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) + return interaction.proMessageFeatures .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) + }() + let proProfileFeatures: SessionPro.ProfileFeatures = { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + return interaction.proProfileFeatures + .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) }() @@ -291,7 +298,19 @@ public extension MessageViewModel { self.expiresInSeconds = interaction.expiresInSeconds self.attachments = attachments self.reactionInfo = (reactionInfo ?? []) - self.profile = targetProfile + self.profile = targetProfile.with( + proFeatures: .set(to: { + guard dependencies[feature: .sessionProEnabled] else { return .none } + + var result: SessionPro.ProfileFeatures = targetProfile.proFeatures + + if dependencies[feature: .proBadgeEverywhere] { + result.insert(.proBadge) + } + + return result + }()) + ) self.quoteViewModel = maybeUnresolvedQuotedInfo.map { info -> QuoteViewModel? in /// Should be `interaction` not `quotedInteraction` let targetDirection: QuoteViewModel.Direction = (interaction.variant.isOutgoing ? @@ -338,14 +357,6 @@ public extension MessageViewModel { attachmentCache: attachmentCache ) let targetQuotedAttachment: Attachment? = (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment) - let quotedInteractionProFeatures: SessionPro.Features = { - guard dependencies[feature: .sessionProEnabled] else { return .none } - - return quotedInteraction.proFeatures - .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) - .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) - .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) - }() return QuoteViewModel( mode: .regular, @@ -390,7 +401,14 @@ public extension MessageViewModel { ) } ), - showProBadge: quotedInteractionProFeatures.contains(.proBadge), + showProBadge: { + guard dependencies[feature: .sessionProEnabled] else { return false } + + return ( + quotedAuthorProfile.proFeatures.contains(.proBadge) || + dependencies[feature: .proBadgeEverywhere] + ) + }(), currentUserSessionIds: currentUserSessionIds, displayNameRetriever: { sessionId, _ in guard !currentUserSessionIds.contains(targetProfile.id) else { return "you".localized() } @@ -403,7 +421,8 @@ public extension MessageViewModel { } self.linkPreview = linkPreviewInfo?.preview self.linkPreviewAttachment = linkPreviewInfo?.attachment - self.proFeatures = proFeatures + self.proMessageFeatures = proMessageFeatures + self.proProfileFeatures = proProfileFeatures self.state = interaction.state self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) @@ -564,7 +583,8 @@ public extension MessageViewModel { quoteViewModel: quoteViewModel, linkPreview: linkPreview, linkPreviewAttachment: linkPreviewAttachment, - proFeatures: proFeatures, + proMessageFeatures: proMessageFeatures, + proProfileFeatures: proProfileFeatures, state: state.or(self.state), hasBeenReadByRecipient: hasBeenReadByRecipient, mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), diff --git a/SessionMessagingKit/Types/Constants+LibSession.swift b/SessionMessagingKit/Types/Constants+LibSession.swift new file mode 100644 index 0000000000..2f26216314 --- /dev/null +++ b/SessionMessagingKit/Types/Constants+LibSession.swift @@ -0,0 +1,119 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionUtil +import SessionUtilitiesKit + +public extension Constants { + static let urls: GeneralUrls = GeneralUrls(SESSION_PROTOCOL_STRINGS) + static let buildVariants: BuildVariants = BuildVariants(SESSION_PROTOCOL_STRINGS) + + enum PaymentProvider { + private static let metadata: [session_pro_backend_payment_provider_metadata] = [ + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.0, /// Empty + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.1, /// Google + SESSION_PRO_BACKEND_PAYMENT_PROVIDER_METADATA.2 /// Apple + ] + + public static let appStore: Info = Info(metadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_IOS_APP_STORE.rawValue)]) + public static let playStore: Info = Info(metadata[Int(SESSION_PRO_BACKEND_PAYMENT_PROVIDER_GOOGLE_PLAY_STORE.rawValue)]) + } +} + +public extension Constants { + struct GeneralUrls: StringProvider.Url { + public let donations: String + public let donationsApp: String + public let download: String + public let faq: String + public let feedback: String + public let network: String + public let privacyPolicy: String + public let proAccessNotFound: String + public let proFaq: String + public let proPrivacyPolicy: String + public let proRoadmap: String + public let proSupport: String + public let proTermsOfService: String + public let staking: String + public let support: String + public let survey: String + public let termsOfService: String + public let token: String + public let translate: String + + fileprivate init(_ libSessionValue: session_protocol_strings) { + self.donations = libSessionValue.get(\.url_donations) + self.donationsApp = libSessionValue.get(\.url_donations_app) + self.download = libSessionValue.get(\.url_download) + self.faq = libSessionValue.get(\.url_faq) + self.feedback = libSessionValue.get(\.url_feedback) + self.network = libSessionValue.get(\.url_network) + self.privacyPolicy = libSessionValue.get(\.url_privacy_policy) + self.proAccessNotFound = libSessionValue.get(\.url_pro_access_not_found) + self.proFaq = libSessionValue.get(\.url_pro_faq) + self.proPrivacyPolicy = libSessionValue.get(\.url_pro_privacy_policy) + self.proRoadmap = libSessionValue.get(\.url_pro_roadmap) + self.proSupport = libSessionValue.get(\.url_pro_support) + self.proTermsOfService = libSessionValue.get(\.url_pro_terms_of_service) + self.staking = libSessionValue.get(\.url_staking) + self.support = libSessionValue.get(\.url_support) + self.survey = libSessionValue.get(\.url_survey) + self.termsOfService = libSessionValue.get(\.url_terms_of_service) + self.token = libSessionValue.get(\.url_token) + self.translate = libSessionValue.get(\.url_translate) + } + } + + struct BuildVariants: StringProvider.BuildVariant { + public let apk: String + public let fDroid: String + public let huawei: String + public let ipa: String + + fileprivate init(_ libSessionValue: session_protocol_strings) { + self.apk = libSessionValue.get(\.build_variant_apk) + self.fDroid = libSessionValue.get(\.build_variant_fdroid) + self.huawei = libSessionValue.get(\.build_variant_huawei) + self.ipa = libSessionValue.get(\.build_variant_ipa) + } + } +} + +public extension Constants.PaymentProvider { + struct Info: StringProvider.ClientPlatform { + public let device: String + public let store: String + public let platform: String + public let platformAccount: String + public let refundPlatformUrl: String + + /// Some platforms disallow a refund via their native support channels after some time period + /// (e.g. 48 hours after a purchase on Google, refunds must be dealt by the developers + /// themselves). If a platform does not have this restriction, this URL is typically the same as + /// the `refund_platform_url`. + public let refundSupportUrl: String + + public let refundStatusUrl: String + public let updateSubscriptionUrl: String + public let cancelSubscriptionUrl: String + + fileprivate init(_ libSessionValue: session_pro_backend_payment_provider_metadata) { + self.device = libSessionValue.get(\.device) + self.store = libSessionValue.get(\.store) + self.platform = libSessionValue.get(\.platform) + self.platformAccount = libSessionValue.get(\.platform_account) + self.refundPlatformUrl = libSessionValue.get(\.refund_platform_url) + + self.refundSupportUrl = libSessionValue.get(\.refund_support_url) + + self.refundStatusUrl = libSessionValue.get(\.refund_status_url) + self.updateSubscriptionUrl = libSessionValue.get(\.update_subscription_url) + self.cancelSubscriptionUrl = libSessionValue.get(\.cancel_subscription_url) + } + } +} + +extension session_protocol_strings: @retroactive CAccessible {} +extension session_pro_backend_payment_provider_metadata: @retroactive CAccessible {} diff --git a/SessionMessagingKit/Types/LinkPreviewManager.swift b/SessionMessagingKit/Types/LinkPreviewManager.swift index 6d96ce63bb..bec7bff653 100644 --- a/SessionMessagingKit/Types/LinkPreviewManager.swift +++ b/SessionMessagingKit/Types/LinkPreviewManager.swift @@ -240,12 +240,13 @@ public actor LinkPreviewManager: LinkPreviewManagerType { return urlMatches } + // stringlint:ignore_contents private func downloadLink( url urlString: String, remainingRetries: UInt = 3 ) async throws -> (Data, URLResponse) { /// We only load Link Previews for HTTPS urls so append an explanation for not - let httpsScheme: String = "https" // stringlint:ignore + let httpsScheme: String = "https" guard URLComponents(string: urlString)?.scheme?.lowercased() == httpsScheme else { throw LinkPreviewError.insecureLink diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 65c3be3575..c05fb2b1e0 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -214,7 +214,7 @@ public struct ProfileEvent: Hashable { case displayPictureUrl(String?) case proStatus( isPro: Bool, - features: SessionPro.Features, + profileFeatures: SessionPro.ProfileFeatures, expiryUnixTimestampMs: UInt64, genIndexHashHex: String? ) diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 6bb09d1a15..29b05a8e30 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -80,12 +80,12 @@ public extension Profile { struct ProState: Equatable { public static let nonPro: ProState = ProState( - features: .none, + profileFeatures: .none, expiryUnixTimestampMs: 0, genIndexHashHex: nil ) - let features: SessionPro.Features + let profileFeatures: SessionPro.ProfileFeatures let expiryUnixTimestampMs: UInt64 let genIndexHashHex: String? @@ -95,11 +95,11 @@ public extension Profile { } init( - features: SessionPro.Features, + profileFeatures: SessionPro.ProfileFeatures, expiryUnixTimestampMs: UInt64, genIndexHashHex: String? ) { - self.features = features + self.profileFeatures = profileFeatures self.expiryUnixTimestampMs = expiryUnixTimestampMs self.genIndexHashHex = genIndexHashHex } @@ -109,7 +109,7 @@ public extension Profile { return nil } - self.features = decodedPro.features + self.profileFeatures = decodedPro.profileFeatures self.expiryUnixTimestampMs = decodedPro.proProof.expiryUnixTimestampMs self.genIndexHashHex = decodedPro.proProof.genIndexHash.toHexString() } @@ -128,7 +128,7 @@ public extension Profile { static func updateLocal( displayNameUpdate: TargetUserUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, - proFeatures: SessionPro.Features? = nil, + proFeatures: SessionPro.ProfileFeatures? = nil, using dependencies: Dependencies ) async throws { /// Perform any non-database related changes for the update @@ -165,13 +165,13 @@ public extension Profile { let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) let proUpdate: TargetUserUpdate = { guard - let targetFeatures: SessionPro.Features = proFeatures, + let targetFeatures: SessionPro.ProfileFeatures = proFeatures, let proof: Network.SessionPro.ProProof = dependencies[singleton: .sessionProManager].currentUserCurrentProProof else { return .none } return .currentUserUpdate( ProState( - features: targetFeatures, + profileFeatures: targetFeatures, expiryUnixTimestampMs: proof.expiryUnixTimestampMs, genIndexHashHex: proof.genIndexHash.toHexString() ) @@ -211,7 +211,7 @@ public extension Profile { let isCurrentUser = currentUserSessionIds.contains(publicKey) let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) let proState: ProState = ProState( - features: profile.proFeatures, + profileFeatures: profile.proFeatures, expiryUnixTimestampMs: profile.proExpiryUnixTimestampMs, genIndexHashHex: profile.proGenIndexHashHex ) @@ -322,14 +322,14 @@ public extension Profile { case .reupload, .config: break /// Don't modify the current state case .staticImage: updatedProState = ProState( - features: updatedProState.features.removing(.animatedAvatar), + profileFeatures: updatedProState.profileFeatures.removing(.animatedAvatar), expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, genIndexHashHex: updatedProState.genIndexHashHex ) case .animatedImage: updatedProState = ProState( - features: updatedProState.features.inserting(.animatedAvatar), + profileFeatures: updatedProState.profileFeatures.inserting(.animatedAvatar), expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, genIndexHashHex: updatedProState.genIndexHashHex ) @@ -338,9 +338,9 @@ public extension Profile { /// If the pro state no longer matches then we need to emit an event if updatedProState != proState { - if updatedProState.features != proState.features { - updatedProfile = updatedProfile.with(proFeatures: .set(to: updatedProState.features)) - profileChanges.append(Profile.Columns.proFeatures.set(to: updatedProState.features.rawValue)) + if updatedProState.profileFeatures != proState.profileFeatures { + updatedProfile = updatedProfile.with(proFeatures: .set(to: updatedProState.profileFeatures)) + profileChanges.append(Profile.Columns.proFeatures.set(to: updatedProState.profileFeatures.rawValue)) } if updatedProState.expiryUnixTimestampMs != proState.expiryUnixTimestampMs { @@ -363,7 +363,7 @@ public extension Profile { id: publicKey, change: .proStatus( isPro: updatedProState.isPro, - features: updatedProState.features, + profileFeatures: updatedProState.profileFeatures, expiryUnixTimestampMs: updatedProState.expiryUnixTimestampMs, genIndexHashHex: updatedProState.genIndexHashHex ) @@ -491,7 +491,7 @@ public extension Profile { displayName: .set(to: updatedProfile.name), displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), - proFeatures: .set(to: updatedProState.features), + proProfileFeatures: .set(to: updatedProState.profileFeatures), isReuploadProfilePicture: { switch displayPictureUpdate { case .currentUserUpdateTo(_, _, let type): return (type == .reupload) @@ -501,6 +501,13 @@ public extension Profile { ) } } + + /// After the commit completes we need to update the SessionProManager to ensure it has the latest state + db.afterCommit { + Task.detached(priority: .userInitiated) { + await dependencies[singleton: .sessionProManager].updateWithLatestFromUserConfig() + } + } } Log.custom(isCurrentUser ? .info : .debug, [.profile], "Successfully updated \(isCurrentUser ? "user profile" : "profile for \(publicKey)")) (\(changeString)).") diff --git a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift index 2c35f4348d..4dc3fe126b 100644 --- a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift +++ b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/Types/HTTPFragmentParam.swift b/SessionNetworkingKit/Types/HTTPFragmentParam.swift index 50821c5496..574229cc5e 100644 --- a/SessionNetworkingKit/Types/HTTPFragmentParam.swift +++ b/SessionNetworkingKit/Types/HTTPFragmentParam.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/Types/HTTPQueryParam.swift b/SessionNetworkingKit/Types/HTTPQueryParam.swift index a350963bdb..f1d9c2b2dc 100644 --- a/SessionNetworkingKit/Types/HTTPQueryParam.swift +++ b/SessionNetworkingKit/Types/HTTPQueryParam.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index f87ceb8fec..0735cb9486 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -415,7 +415,7 @@ final class ShareNavController: UINavigationController { case .none: return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "missing item provider" + description: "missing item provider" // stringlint:ignore ) ) @@ -423,7 +423,7 @@ final class ShareNavController: UINavigationController { guard let tempFilePath = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: data) else { return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Error writing item data" + description: "Error writing item data" // stringlint:ignore ) ) } @@ -478,7 +478,7 @@ final class ShareNavController: UINavigationController { catch { return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Failed to copy temporary file: \(error)" + description: "Failed to copy temporary file: \(error)" // stringlint:ignore ) ) } @@ -497,7 +497,7 @@ final class ShareNavController: UINavigationController { // don't know how to handle. return continuation.resume( throwing: ShareViewControllerError.assertionError( - description: "Unexpected value: \(String(describing: value))" + description: "Unexpected value: \(String(describing: value))" // stringlint:ignore ) ) } @@ -759,14 +759,18 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { return dependencies[singleton: .sessionProManager].numberOfCharactersLeft(for: text) } - func proUrlStringProvider() -> SessionProUI.UrlStringProvider { - return SessionPro.Metadata.urls + func urlStringProvider() -> StringProvider.Url { + return Constants.urls } - func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + func buildVariantStringProvider() -> StringProvider.BuildVariant { + return Constants.buildVariants + } + + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { switch platform { - case .iOS: return SessionPro.Metadata.appStore - case .android: return SessionPro.Metadata.playStore + case .iOS: return Constants.PaymentProvider.appStore + case .android: return Constants.PaymentProvider.playStore } } } diff --git a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift index 64db1d918d..23583d7d56 100644 --- a/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift +++ b/SessionUIKit/Components/SwiftUI/AnimatedToggle.swift @@ -5,6 +5,7 @@ import SwiftUI public struct AnimatedToggle: View { let value: Bool let oldValue: Bool? + let allowHitTesting: Bool let accessibility: Accessibility @State private var uiValue: Bool @@ -12,16 +13,19 @@ public struct AnimatedToggle: View { public init( value: Bool, oldValue: Bool?, + allowHitTesting: Bool, accessibility: Accessibility ) { self.value = value self.oldValue = oldValue + self.allowHitTesting = allowHitTesting self.accessibility = accessibility _uiValue = State(initialValue: oldValue ?? value) } public var body: some View { Toggle("", isOn: $uiValue) + .allowsHitTesting(allowHitTesting) .labelsHidden() .accessibility(accessibility) .tint(themeColor: .primary) diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 7f647ebab0..20816249f2 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -28,8 +28,9 @@ public actor SNUIKit { @MainActor func numberOfCharactersLeft(for text: String) -> Int - func proUrlStringProvider() -> SessionProUI.UrlStringProvider - func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider + func urlStringProvider() -> StringProvider.Url + func buildVariantStringProvider() -> StringProvider.BuildVariant + func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform } @MainActor public static var mainWindow: UIWindow? = nil @@ -143,23 +144,33 @@ public actor SNUIKit { return (config?.numberOfCharactersLeft(for: text) ?? 0) } - internal static func proUrlStringProvider() -> SessionProUI.UrlStringProvider { + internal static func urlStringProvider() -> StringProvider.Url { configLock.lock() defer { configLock.unlock() } return ( - config?.proUrlStringProvider() ?? - SessionProUI.FallbackUrlStringProvider() + config?.urlStringProvider() ?? + StringProvider.FallbackUrlStringProvider() ) } - internal static func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatformStringProvider { + internal static func buildVariantStringProvider() -> StringProvider.BuildVariant { + configLock.lock() + defer { configLock.unlock() } + + return ( + config?.buildVariantStringProvider() ?? + StringProvider.FallbackBuildVariantStringProvider() + ) + } + + internal static func proClientPlatformStringProvider(for platform: SessionProUI.ClientPlatform) -> StringProvider.ClientPlatform { configLock.lock() defer { configLock.unlock() } return ( config?.proClientPlatformStringProvider(for: platform) ?? - SessionProUI.FallbackClientPlatformStringProvider() + StringProvider.FallbackClientPlatformStringProvider() ) } } diff --git a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift index cb4d2ef720..09e3233f33 100644 --- a/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift +++ b/SessionUIKit/Screens/SessionListScreen/AccessoryViews/ListItemAccessory+Toggle.swift @@ -12,6 +12,7 @@ public extension SessionListScreenContent.ListItemAccessory { AnimatedToggle( value: value, oldValue: oldValue, + allowHitTesting: false, /// Disable hit testing as the `ListItem` should receive the touches accessibility: accessibility ) } diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift index 6a0caba3a6..8feb0d7240 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -25,7 +25,7 @@ public struct SessionNetworkScreen UIImage { - var backgroundColor: UIColor = .white - var tintColor: UIColor = .classicDark1 + let backgroundColor: UIColor = .white + let tintColor: UIColor = .classicDark1 let outputSize = size ?? image.size let renderer = UIGraphicsImageRenderer(size: outputSize) diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 3c4159564d..167061030c 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -79,6 +79,7 @@ public extension String.StringInterpolation { } public extension String { + // stringlint:ignore_contents static func formattedDuration( _ duration: TimeInterval, format: TimeInterval.DurationFormat = .short, diff --git a/SessionUtilitiesKit/Types/AnyCodable.swift b/SessionUtilitiesKit/Types/AnyCodable.swift index 6a5ab329c6..15615aa56f 100644 --- a/SessionUtilitiesKit/Types/AnyCodable.swift +++ b/SessionUtilitiesKit/Types/AnyCodable.swift @@ -36,7 +36,7 @@ public struct AnyCodable: Codable { else { throw DecodingError.dataCorruptedError( in: container, - debugDescription: "Unsupported JSON type" + debugDescription: "Unsupported JSON type" // stringlint:disable ) } } From c4dce92d4ba44a20051fb0c1693f082b25981f72 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 1 Dec 2025 13:02:42 +1100 Subject: [PATCH 28/66] Added a couple more mocks --- Session.xcodeproj/project.pbxproj | 28 ++++--- Session/Home/HomeViewModel.swift | 4 +- ...perSettingsModalsAndBannersViewModel.swift | 4 +- .../SessionProPaymentScreen+ViewModel.swift | 2 +- .../SessionPro/SessionProManager.swift | 77 +++++++++++++------ .../Types/SessionProOriginatingAccount.swift | 26 +++++++ .../Types/SessionProRefundingStatus.swift | 4 +- .../Utilities/SessionProMocking.swift | 38 +++++++-- ...essionProPaymentScreen+RequestRefund.swift | 6 +- .../SessionProPaymentScreen.swift | 4 +- SessionUIKit/Types/BuildVariant.swift | 49 ++++++++++++ SessionUIKit/Types/StringProviders.swift | 6 ++ 12 files changed, 195 insertions(+), 53 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5af614ce90..96eaf2e046 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -621,6 +621,8 @@ FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; }; FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; }; FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; + FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */; }; + FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */; }; FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; @@ -674,14 +676,14 @@ FD360EC32ECD23A40050CAF4 /* GetProRevocationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */; }; FD360EC52ECD24C30050CAF4 /* RevocationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */; }; FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */; }; + FD360EC92ECD3EB20050CAF4 /* DonationCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */; }; + FD360ECB2ECD59550050CAF4 /* DonationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */; }; + FD360ECD2ECD70590050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */; }; FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */; }; FD360ED12ECFB8AC0050CAF4 /* SessionProExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */; }; FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */; }; FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */; }; - FD360EC92ECD3EB20050CAF4 /* DonationCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */; }; - FD360ECB2ECD59550050CAF4 /* DonationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */; }; - FD360ECD2ECD70590050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */; }; FD360EDA2ED3E8BC0050CAF4 /* DonationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */; }; FD360EDB2ED3E8BC0050CAF4 /* DonationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */; }; FD368A6829DE8F9C000DBF1E /* _026_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */; }; @@ -2107,6 +2109,8 @@ FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = ""; }; FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = ""; }; FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; + FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProOriginatingAccount.swift; sourceTree = ""; }; + FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildVariant.swift; sourceTree = ""; }; FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsRequest.swift; sourceTree = ""; }; FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsResponse.swift; sourceTree = ""; }; @@ -2133,13 +2137,13 @@ FD360EC22ECD239D0050CAF4 /* GetProRevocationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProRevocationsResponse.swift; sourceTree = ""; }; FD360EC42ECD24C00050CAF4 /* RevocationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevocationItem.swift; sourceTree = ""; }; FD360EC62ECD38710050CAF4 /* OptionSet+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionSet+Utilities.swift"; sourceTree = ""; }; + FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCTAModal.swift; sourceTree = ""; }; + FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationsManager.swift; sourceTree = ""; }; + FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsModalsAndBannersViewModel.swift; sourceTree = ""; }; FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProLoadingState.swift; sourceTree = ""; }; FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExpiry.swift; sourceTree = ""; }; FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationUtilities.swift; sourceTree = ""; }; FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPlan.swift; sourceTree = ""; }; - FD360EC82ECD3EAE0050CAF4 /* DonationCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCTAModal.swift; sourceTree = ""; }; - FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationsManager.swift; sourceTree = ""; }; - FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsModalsAndBannersViewModel.swift; sourceTree = ""; }; FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = DonationsCTA.webp; sourceTree = ""; }; FD368A6729DE8F9B000DBF1E /* _026_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _026_AddFTSIfNeeded.swift; sourceTree = ""; }; FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = ""; }; @@ -4989,13 +4993,14 @@ FD71163028E2C41900B47552 /* Types */ = { isa = PBXGroup; children = ( - FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */, FDE6E99729F8E63A00F93C5D /* Accessibility.swift */, + FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */, FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */, + FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */, FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */, ); path = Types; @@ -5351,15 +5356,16 @@ children = ( FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, + FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, - FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, - FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */, + FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */, FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, - FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, + FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */, + FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, ); path = Types; sourceTree = ""; @@ -6808,6 +6814,7 @@ FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */, 94519A972E851F1400F02723 /* SessionProPaymentScreen+RequestRefund.swift in Sources */, FD9E26B32EA72CC500404C7F /* UIEdgeInsets+Utilities.swift in Sources */, + FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */, FD8A5B0D2DBF2CA1004C689B /* Localization.swift in Sources */, 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, @@ -7378,6 +7385,7 @@ FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */, FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, FD37EA0D28AB2A45003AE748 /* _013_FixDeletedMessageReadState.swift in Sources */, + FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */, FDD23AE32E457CFE0057E853 /* _010_FlagMessageHashAsDeletedOrInvalid.swift in Sources */, 7BAA7B6628D2DE4700AE1489 /* _018_OpenGroupPermission.swift in Sources */, FD2286692C37DA5500BC06F7 /* PollerType.swift in Sources */, diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 38004aaaa3..70be4e99c8 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -665,10 +665,10 @@ public class HomeViewModel: NavigatableStateHolder { @MainActor func showSessionProCTAIfNeeded() async { let status: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager].proStatus.first(defaultValue: nil) - let isRefunding: SessionPro.IsRefunding = await dependencies[singleton: .sessionProManager].isRefunding.first(defaultValue: .notRefunding) + let refundingStatus: SessionPro.RefundingStatus = await dependencies[singleton: .sessionProManager].refundingStatus.first(defaultValue: .notRefunding) let variant: ProCTAModal.Variant - switch (status, isRefunding) { + switch (status, refundingStatus) { case (.none, _), (.neverBeenPro, _), (.active, .refunding): return case (.active, .notRefunding): diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift index 6611d7ca5f..5965e6d096 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsModalsAndBannersViewModel.swift @@ -128,8 +128,8 @@ class DeveloperSettingsModalsAndBannersViewModel: SessionTableViewModel, Navigat public struct State: Equatable, ObservableKeyProvider { let donationsCTAModalAppearanceCount: Int - let donationsCTAModalLastAppearanceTimestamp: TimeInterval? - let customFirstInstallDateTime: TimeInterval? + let donationsCTAModalLastAppearanceTimestamp: TimeInterval + let customFirstInstallDateTime: TimeInterval let donationsUrlOpenCount: Int let donationsUrlCopyCount: Int diff --git a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift b/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift index 622529711a..b0743f0e5c 100644 --- a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift @@ -13,7 +13,7 @@ extension SessionProPaymentScreenContent { private var dependencies: Dependencies - init(dependencies: Dependencies, dataModel: DataModel) { + init(dataModel: DataModel, dependencies: Dependencies) { self.dependencies = dependencies self.dataModel = dataModel } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 6c80f0fdab..d32713a6c5 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -34,13 +34,14 @@ public actor SessionProManager: SessionProManagerType { private var rotatingKeyPair: KeyPair? public var plans: [SessionPro.Plan] = [] + nonisolated private let buildVariantStream: CurrentValueAsyncStream = CurrentValueAsyncStream(BuildVariant.current) nonisolated private let loadingStateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.loading) nonisolated private let proStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let autoRenewingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let accessExpiryTimestampMsStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let latestPaymentItemStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let latestPaymentOriginatingPlatformStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.iOS) - nonisolated private let isRefundingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(false) + nonisolated private let refundingStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.notRefunding) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } nonisolated public var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { @@ -60,6 +61,7 @@ public actor SessionProManager: SessionProManagerType { .asAsyncStream() } + nonisolated public var buildVariant: AsyncStream { buildVariantStream.stream } nonisolated public var loadingState: AsyncStream { loadingStateStream.stream } nonisolated public var proStatus: AsyncStream { proStatusStream.stream } nonisolated public var autoRenewing: AsyncStream { autoRenewingStream.stream } @@ -68,7 +70,7 @@ public actor SessionProManager: SessionProManagerType { nonisolated public var latestPaymentOriginatingPlatform: AsyncStream { latestPaymentOriginatingPlatformStream.stream } - nonisolated public var isRefunding: AsyncStream { isRefundingStream.stream } + nonisolated public var refundingStatus: AsyncStream { refundingStatusStream.stream } // MARK: - Initialization @@ -239,7 +241,7 @@ public actor SessionProManager: SessionProManagerType { self.rotatingKeyPair = rotatingKeyPair await self.proStatusStream.send(mockedIfNeeded(proStatus)) await self.accessExpiryTimestampMsStream.send(proState.accessExpiryTimestampMs) - await self.sendUpdatedIsRefundingState() + await self.sendUpdatedRefundingStatusState() /// If the `accessExpiryTimestampMs` value changed then we should trigger a refresh because it generally means that /// other device did something that should refresh the pro state @@ -282,7 +284,7 @@ public actor SessionProManager: SessionProManagerType { // TODO: [PRO] Need to actually implement this dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .simulate(.active)) await proStatusStream.send(.active) - await sendUpdatedIsRefundingState() + await sendUpdatedRefundingStatusState() completion?(true) } @@ -331,7 +333,7 @@ public actor SessionProManager: SessionProManagerType { await self.latestPaymentOriginatingPlatformStream.send(mockedIfNeeded( SessionProUI.ClientPlatform(response.items.first?.paymentProvider) )) - await self.sendUpdatedIsRefundingState() + await self.sendUpdatedRefundingStatusState() switch response.status { case .active: @@ -425,7 +427,7 @@ public actor SessionProManager: SessionProManagerType { ) self.rotatingKeyPair = rotatingKeyPair await self.proStatusStream.send(mockedIfNeeded(proStatus)) - await self.sendUpdatedIsRefundingState() + await self.sendUpdatedRefundingStatusState() } public func addProPayment(transactionId: String) async throws { @@ -482,7 +484,7 @@ public actor SessionProManager: SessionProManagerType { ) self.rotatingKeyPair = rotatingKeyPair await self.proStatusStream.send(mockedIfNeeded(proStatus)) - await self.sendUpdatedIsRefundingState() + await self.sendUpdatedRefundingStatusState() /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other /// edge-cases since it's the main driver to the Pro state) @@ -565,13 +567,13 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Internal Functions /// The user is in a refunding state when their pro status is `active` and the `refundRequestedTimestampMs` is not `0` - private func sendUpdatedIsRefundingState() async { + private func sendUpdatedRefundingStatusState() async { let status: Network.SessionPro.BackendUserProStatus? = await proStatusStream.getCurrent() let paymentItem: Network.SessionPro.PaymentItem? = await latestPaymentItemStream.getCurrent() - await isRefundingStream.send( + await refundingStatusStream.send( mockedIfNeeded( - SessionPro.IsRefunding( + SessionPro.RefundingStatus( status == .active && (paymentItem?.refundRequestedTimestampMs ?? 0) > 0 ) @@ -647,14 +649,15 @@ public protocol SessionProManagerType: SessionProUIManagerType { nonisolated var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { get } nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } nonisolated var currentUserCurrentProProfileFeatures: SessionPro.ProfileFeatures? { get } - + // TODO: [PRO] Need to finish off the "buildVariant" logic + nonisolated var buildVariant: AsyncStream { get } nonisolated var loadingState: AsyncStream { get } nonisolated var proStatus: AsyncStream { get } nonisolated var autoRenewing: AsyncStream { get } nonisolated var accessExpiryTimestampMs: AsyncStream { get } nonisolated var latestPaymentItem: AsyncStream { get } nonisolated var latestPaymentOriginatingPlatform: AsyncStream { get } - nonisolated var isRefunding: AsyncStream { get } + nonisolated var refundingStatus: AsyncStream { get } nonisolated func proStatus( for proof: Network.SessionPro.ProProof?, @@ -735,11 +738,11 @@ public extension ObservableKey { ) { [weak manager] in manager?.latestPaymentOriginatingPlatform } } - static func currentUserProIsRefunding(_ manager: SessionProManagerType) -> ObservableKey { + static func currentUserProRefundingStatus(_ manager: SessionProManagerType) -> ObservableKey { return ObservableKey.stream( - key: "currentUserProIsRefunding", - generic: .currentUserProIsRefunding - ) { [weak manager] in manager?.isRefunding } + key: "currentUserProRefundingStatus", + generic: .currentUserProRefundingStatus + ) { [weak manager] in manager?.refundingStatus } } } @@ -751,7 +754,7 @@ public extension GenericObservableKey { static let currentUserProAccessExpiryTimestampMs: GenericObservableKey = "currentUserProAccessExpiryTimestampMs" static let currentUserProLatestPaymentItem: GenericObservableKey = "currentUserProLatestPaymentItem" static let currentUserLatestPaymentOriginatingPlatform: GenericObservableKey = "currentUserLatestPaymentOriginatingPlatform" - static let currentUserProIsRefunding: GenericObservableKey = "currentUserProIsRefunding" + static let currentUserProRefundingStatus: GenericObservableKey = "currentUserProRefundingStatus" } // MARK: - Mocking @@ -781,7 +784,7 @@ private extension SessionProManager { await self?.autoRenewingStream.send(nil) await self?.accessExpiryTimestampMsStream.send(nil) await self?.latestPaymentItemStream.send(nil) - await self?.sendUpdatedIsRefundingState() + await self?.sendUpdatedRefundingStatusState() return } @@ -803,7 +806,17 @@ private extension SessionProManager { default: break } - switch (state.previousInfo?.mockIsRefunding, state.info.mockIsRefunding) { + switch (state.previousInfo?.mockOriginatingPlatform, state.info.mockOriginatingPlatform) { + case (.simulate, .useActual): return true + default: break + } + + switch (state.previousInfo?.mockBuildVariant, state.info.mockBuildVariant) { + case (.simulate, .useActual): return true + default: break + } + + switch (state.previousInfo?.mockRefundingStatus, state.info.mockRefundingStatus) { case (.simulate, .useActual): return true default: break } @@ -826,7 +839,7 @@ private extension SessionProManager { case .simulate(let value): self?.syncState.update(proStatus: .set(to: value)) await self?.proStatusStream.send(value) - await self?.sendUpdatedIsRefundingState() + await self?.sendUpdatedRefundingStatusState() } } @@ -837,10 +850,24 @@ private extension SessionProManager { } } - if state.info.mockIsRefunding != state.previousInfo?.mockIsRefunding { - switch state.info.mockIsRefunding { + if state.info.mockOriginatingPlatform != state.previousInfo?.mockOriginatingPlatform { + switch state.info.mockBuildVariant { + case .useActual: break + case .simulate(let value): await self?.latestPaymentOriginatingPlatformStream.send(value) + } + } + + if state.info.mockBuildVariant != state.previousInfo?.mockBuildVariant { + switch state.info.mockBuildVariant { + case .useActual: break + case .simulate(let value): await self?.buildVariantStream.send(value) + } + } + + if state.info.mockRefundingStatus != state.previousInfo?.mockRefundingStatus { + switch state.info.mockRefundingStatus { case .useActual: break - case .simulate(let value): await self?.isRefundingStream.send(value) + case .simulate(let value): await self?.refundingStatusStream.send(value) } } } @@ -868,8 +895,8 @@ private extension SessionProManager { } } - private func mockedIfNeeded(_ value: SessionPro.IsRefunding) -> SessionPro.IsRefunding { - switch dependencies[feature: .mockCurrentUserSessionProIsRefunding] { + private func mockedIfNeeded(_ value: SessionPro.RefundingStatus) -> SessionPro.RefundingStatus { + switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { case .simulate(let mockedValue): return mockedValue case .useActual: return value } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift b/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift index e69de29bb2..4de126d0a1 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProOriginatingAccount.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SessionPro { + enum OriginatingAccount: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { + case originatingAccount + case nonOriginatingAccount + + public init(booleanLiteral value: Bool) { + self = (value ? .originatingAccount : .nonOriginatingAccount) + } + + public init(_ value: Bool) { + self = OriginatingAccount(booleanLiteral: value) + } + + // stringlint:ignore_contents + public var description: String { + switch self { + case .originatingAccount: return "Originating Account" + case .nonOriginatingAccount: return "Non-originating Account" + } + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift index e7c5f66b06..27f5fad5e0 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProRefundingStatus.swift @@ -3,7 +3,7 @@ import Foundation public extension SessionPro { - enum IsRefunding: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { + enum RefundingStatus: Sendable, Equatable, Hashable, CaseIterable, CustomStringConvertible, ExpressibleByBooleanLiteral { case notRefunding case refunding @@ -12,7 +12,7 @@ public extension SessionPro { } public init(_ value: Bool) { - self = IsRefunding(booleanLiteral: value) + self = RefundingStatus(booleanLiteral: value) } // stringlint:ignore_contents diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift index 14dd489788..12d2045f60 100644 --- a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift +++ b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift @@ -16,7 +16,8 @@ internal extension SessionProManager { let mockProLoadingState: MockableFeature let mockProBackendStatus: MockableFeature let mockOriginatingPlatform: MockableFeature - let mockIsRefunding: MockableFeature + let mockBuildVariant: MockableFeature + let mockRefundingStatus: MockableFeature } let previousInfo: Info? @@ -27,7 +28,8 @@ internal extension SessionProManager { .feature(.mockCurrentUserSessionProLoadingState), .feature(.mockCurrentUserSessionProBackendStatus), .feature(.mockCurrentUserSessionProOriginatingPlatform), - .feature(.mockCurrentUserSessionProIsRefunding) + .feature(.mockCurrentUserSessionProBuildVariant), + .feature(.mockCurrentUserSessionProRefundingStatus) ] init(previousInfo: Info? = nil, using dependencies: Dependencies) { @@ -37,7 +39,8 @@ internal extension SessionProManager { mockProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], mockProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], mockOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], - mockIsRefunding: dependencies[feature: .mockCurrentUserSessionProIsRefunding] + mockBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], + mockRefundingStatus: dependencies[feature: .mockCurrentUserSessionProRefundingStatus] ) } } @@ -112,15 +115,36 @@ extension SessionProUI.ClientPlatform: @retroactive MockableFeatureValue { } } -// MARK: - SessionPro.IsRefunding +// MARK: - SessionProUI.BuildVariant public extension FeatureStorage { - static let mockCurrentUserSessionProIsRefunding: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProIsRefunding" + static let mockCurrentUserSessionProBuildVariant: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBuildVariant" ) } -extension SessionPro.IsRefunding: MockableFeatureValue { +extension SessionProUI.BuildVariant: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .apk: return "The app was installed directly as an APK." + case .fDroid: return "The app was installed via fDroid." + case .huawei: return "The app is a Huawei build." + case .ipa: return "The app was installed direcrtly as an IPA." + } + } +} + +// MARK: - SessionPro.RefundingStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProRefundingStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProRefundingStatus" + ) +} + +extension SessionPro.RefundingStatus: MockableFeatureValue { public var title: String { "\(self)" } public var subtitle: String { diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift index b9ef4fe4a4..bca2bc0185 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+RequestRefund.swift @@ -161,14 +161,16 @@ struct RequestRefundNonOriginatorContent: View { return "refundNonOriginatorApple" .put(key: "app_pro", value: Constants.app_pro) .put(key: "pro", value: Constants.pro) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) + case (_, true): return "proPlanPlatformRefund" .put(key: "app_pro", value: Constants.app_pro) .put(key: "platform_store", value: originatingPlatform.store) - .put(key: "platform_account", value: originatingPlatform.account) + .put(key: "platform_account", value: originatingPlatform.platformAccount) .localizedFormatted(Fonts.Body.baseRegular) + case (_, false): return "proPlanPlatformRefundLong" .put(key: "app_pro", value: Constants.app_pro) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 3a3d8dc9a0..1aa2870f24 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -82,7 +82,7 @@ public struct SessionProPaymentScreen: View { ) } - case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform): + case .update(let currentPlan, let expiredOn, let originatingPlatform, let isAutoRenewing): if viewModel.dataModel.plans.isEmpty || originatingPlatform != .iOS { UpdatePlanNonOriginatingPlatformContent( currentPlan: currentPlan, @@ -198,7 +198,7 @@ public struct SessionProPaymentScreen: View { private func updatePlan() { let updatedPlan = viewModel.dataModel.plans[currentSelection] if - case .update(let currentPlan, let expiredOn, let isAutoRenewing, _) = viewModel.dataModel.flow, + case .update(let currentPlan, let expiredOn, _, let isAutoRenewing) = viewModel.dataModel.flow, let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) { let confirmationModal = ConfirmationModal( diff --git a/SessionUIKit/Types/BuildVariant.swift b/SessionUIKit/Types/BuildVariant.swift index e69de29bb2..c9e0e20c59 100644 --- a/SessionUIKit/Types/BuildVariant.swift +++ b/SessionUIKit/Types/BuildVariant.swift @@ -0,0 +1,49 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum BuildVariant: Sendable, Equatable, CaseIterable, CustomStringConvertible { + case appStore + case development + case testFlight + case ipa + + /// Non-iOS variants (may be used for copy) + case apk + case fDroid + case huawei + + public static var current: BuildVariant { +#if DEBUG + return .development +#else + + let hasProvisioningProfile: Bool = (Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") != nil) + let receiptUrl: URL? = Bundle.main.appStoreReceiptURL + let hasSandboxReceipt: Bool = (receiptURL?.lastPathComponent == "sandboxReceipt") + + if !hasProvisioningProfile { + return .appStore + } + + if hasSandboxReceipt { + return .testFlight + } + + return .ipa +#endif + } + + public var description: String { + switch self { + case .appStore: return SNUIKit.buildVariantStringProvider().appStore + case .development: return SNUIKit.buildVariantStringProvider().development + case .testFlight: return SNUIKit.buildVariantStringProvider().testFlight + case .ipa: return SNUIKit.buildVariantStringProvider().ipa + + case .apk: return SNUIKit.buildVariantStringProvider().apk + case .fDroid: return SNUIKit.buildVariantStringProvider().fDroid + case .huawei: return SNUIKit.buildVariantStringProvider().huawei + } + } +} diff --git a/SessionUIKit/Types/StringProviders.swift b/SessionUIKit/Types/StringProviders.swift index d0a71a906b..05ed9b606d 100644 --- a/SessionUIKit/Types/StringProviders.swift +++ b/SessionUIKit/Types/StringProviders.swift @@ -23,9 +23,12 @@ public extension StringProvider { protocol BuildVariant { var apk: String { get } + var appStore: String { get } + var development: String { get } var fDroid: String { get } var huawei: String { get } var ipa: String { get } + var testFlight: String { get } } protocol ClientPlatform { @@ -64,9 +67,12 @@ internal extension StringProvider { /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) struct FallbackBuildVariantStringProvider: StringProvider.BuildVariant { let apk: String = "APK" + let appStore: String = "Apple App Store" + let development: String = "Development" let fDroid: String = "F-Droid Store" let huawei: String = "Huawei App Gallery" let ipa: String = "IPA" + let testFlight: String = "TestFlight" } /// This type should not be used where possible as it's values aren't maintained (proper values are sourced from `libSession`) From 53a7b1b78f1cf4a4913b7a9cd1f0300387421dde Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 1 Dec 2025 17:07:15 +1100 Subject: [PATCH 29/66] Fixed a bunch of build errors from the merge --- .../Message Cells/VisibleMessageCell.swift | 4 -- Session/Home/HomeViewModel.swift | 32 +++++++++-- .../MessageInfoScreen.swift | 2 +- .../DeveloperSettingsViewModel.swift | 10 ++-- .../SessionProSettingsViewModel.swift | 34 +++++++++--- Session/Settings/SettingsViewModel.swift | 4 +- Session/Utilities/DonationsManager.swift | 2 +- .../UIContextualAction+Utilities.swift | 9 +-- .../SessionPro/SessionProManager.swift | 55 ++++++++++++++++++- .../Types/Constants+LibSession.swift | 10 +++- .../Components/SwiftUI/ProCTAModal.swift | 51 +++-------------- .../SessionProPaymentScreen+Models.swift | 4 +- .../Types/SessionProUIManagerType.swift | 25 ++++----- 13 files changed, 148 insertions(+), 94 deletions(-) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index b126d0503c..2563ed088a 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -578,7 +578,6 @@ final class VisibleMessageCell: MessageCell { with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, - delegate: self, displayNameRetriever: displayNameRetriever ) @@ -645,7 +644,6 @@ final class VisibleMessageCell: MessageCell { with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, - delegate: self, displayNameRetriever: displayNameRetriever ) self.bodyLabel = bodyTappableLabel @@ -730,7 +728,6 @@ final class VisibleMessageCell: MessageCell { with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, - delegate: self, displayNameRetriever: displayNameRetriever ) self.bodyLabel = bodyTappableLabel @@ -764,7 +761,6 @@ final class VisibleMessageCell: MessageCell { with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, - delegate: self, displayNameRetriever: displayNameRetriever ) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 70be4e99c8..b1f737bf91 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -702,17 +702,39 @@ public class HomeViewModel: NavigatableStateHolder { try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread + let plans: [SessionPro.Plan] = await dependencies[singleton: .sessionProManager].plans + let proStatus: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager].proStatus + .first(defaultValue: nil) + let proAutoRenewing: Bool? = await dependencies[singleton: .sessionProManager].autoRenewing + .first(defaultValue: nil) + let proAccessExpiryTimestampMs: UInt64? = await dependencies[singleton: .sessionProManager].accessExpiryTimestampMs + .first(defaultValue: nil) + let proLatestPaymentItem: Network.SessionPro.PaymentItem? = await dependencies[singleton: .sessionProManager].latestPaymentItem + .first(defaultValue: nil) + let proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform = await dependencies[singleton: .sessionProManager].latestPaymentOriginatingPlatform + .first(defaultValue: .iOS) + let proRefundingStatus: SessionPro.RefundingStatus = await dependencies[singleton: .sessionProManager].refundingStatus + .first(defaultValue: .notRefunding) + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( variant, - onConfirm: { + onConfirm: { [weak self, dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, dataModel: SessionProPaymentScreenContent.DataModel( - flow: dependencies[singleton: .sessionProState].sessionProStateSubject.value.toPaymentFlow(using: dependencies), - plans: dependencies[singleton: .sessionProState].sessionProPlans.map { $0.info() } - ) + flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow( + plans: plans, + proStatus: proStatus, + autoRenewing: proAutoRenewing, + accessExpiryTimestampMs: proAccessExpiryTimestampMs, + latestPaymentItem: proLatestPaymentItem, + lastPaymentOriginatingPlatform: proLastPaymentOriginatingPlatform, + refundingStatus: proRefundingStatus + ), + plans: plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + ), + dependencies: dependencies ) ) ) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 97898f6f9a..61b7300907 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -461,7 +461,7 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: .textPrimary) } - if viewModel.proFeatures.contains(.proBadge) { + if viewModel.shouldShowProBadge { SessionProBadge_SwiftUI(size: .small) .onTapGesture { showSessionProCTAIfNeeded() diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index f0582256ad..b7d01851f9 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -2086,12 +2086,14 @@ extension DeveloperSettingsViewModel { } }() - switch selectedValue { - case .none, .useActual: - dependencies?.set(feature: feature, to: nil) + let finalValue: MockableFeature = (selectedValue ?? .useActual) + + switch finalValue { + case .useActual: + dependencies?.reset(feature: feature) onMockingRemoved?() - case .simulate: dependencies?.set(feature: feature, to: selectedValue) + case .simulate: dependencies?.set(feature: feature, to: finalValue) } } ) diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 17c8740d4c..a33d3e3c04 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -129,6 +129,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType public struct State: ObservableKeyProvider { let profile: Profile + let buildVariant: BuildVariant let loadingState: SessionPro.LoadingState let numberOfGroupsUpgraded: Int let numberOfPinnedConversations: Int @@ -140,6 +141,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let proAccessExpiryTimestampMs: UInt64? let proLatestPaymentItem: Network.SessionPro.PaymentItem? let proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform + let proOriginatingAccount: SessionPro.OriginatingAccount let proRefundingStatus: SessionPro.RefundingStatus @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] { @@ -159,12 +161,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return [ .anyConversationPinnedPriorityChanged, .profile(profile.id), + .buildVariant(sessionProManager), .currentUserProLoadingState(sessionProManager), .currentUserProStatus(sessionProManager), .currentUserProAutoRenewing(sessionProManager), .currentUserProAccessExpiryTimestampMs(sessionProManager), .currentUserProLatestPaymentItem(sessionProManager), .currentUserLatestPaymentOriginatingPlatform(sessionProManager), + .currentUserProOriginatingAccount(sessionProManager), .currentUserProRefundingStatus(sessionProManager), .setting(.groupsUpgradedCounter), .setting(.proBadgesSentCounter), @@ -175,6 +179,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType static func initialState(using dependencies: Dependencies) -> State { return State( profile: dependencies.mutate(cache: .libSession) { $0.profile }, + buildVariant: BuildVariant.current, loadingState: .loading, numberOfGroupsUpgraded: 0, numberOfPinnedConversations: 0, @@ -186,6 +191,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proAccessExpiryTimestampMs: nil, proLatestPaymentItem: nil, proLastPaymentOriginatingPlatform: .iOS, + proOriginatingAccount: .originatingAccount, proRefundingStatus: false ) } @@ -198,6 +204,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType using dependencies: Dependencies ) async -> State { var profile: Profile = previousState.profile + var buildVariant: BuildVariant = previousState.buildVariant var loadingState: SessionPro.LoadingState = previousState.loadingState var numberOfGroupsUpgraded: Int = previousState.numberOfGroupsUpgraded var numberOfPinnedConversations: Int = previousState.numberOfPinnedConversations @@ -209,6 +216,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType var proAccessExpiryTimestampMs: UInt64? = previousState.proAccessExpiryTimestampMs var proLatestPaymentItem: Network.SessionPro.PaymentItem? = previousState.proLatestPaymentItem var proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform = previousState.proLastPaymentOriginatingPlatform + var proOriginatingAccount: SessionPro.OriginatingAccount = previousState.proOriginatingAccount var proRefundingStatus: SessionPro.RefundingStatus = previousState.proRefundingStatus /// Store a local copy of the events so we can manipulate it based on the state changes @@ -257,6 +265,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) /// Process any general event changes + if let value = changes.latest(.buildVariant, as: BuildVariant.self) { + buildVariant = value + } + if let value = changes.latest(.currentUserProLoadingState, as: SessionPro.LoadingState.self) { loadingState = value } @@ -281,6 +293,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proLastPaymentOriginatingPlatform = value } + if let value = changes.latest(.currentUserProOriginatingAccount, as: SessionPro.OriginatingAccount.self) { + proOriginatingAccount = value + } + if let value = changes.latest(.currentUserProRefundingStatus, as: SessionPro.RefundingStatus.self) { proRefundingStatus = value } @@ -332,6 +348,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return State( profile: profile, + buildVariant: buildVariant, loadingState: loadingState, numberOfGroupsUpgraded: numberOfGroupsUpgraded, numberOfPinnedConversations: numberOfPinnedConversations, @@ -343,6 +360,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proAccessExpiryTimestampMs: proAccessExpiryTimestampMs, proLatestPaymentItem: proLatestPaymentItem, proLastPaymentOriginatingPlatform: proLastPaymentOriginatingPlatform, + proOriginatingAccount: proOriginatingAccount, proRefundingStatus: proRefundingStatus ) } @@ -1135,7 +1153,7 @@ extension SessionProSettingsViewModel { } @MainActor func updateProPlan(state: State) { - guard !dependencies[feature: .mockInstalledFromIPA] else { + guard state.buildVariant != .ipa else { let viewController = ModalActivityIndicatorViewController() { [weak self] modalActivityIndicator in Task { sleep(5) @@ -1159,6 +1177,7 @@ extension SessionProSettingsViewModel { accessExpiryTimestampMs: state.proAccessExpiryTimestampMs, latestPaymentItem: state.proLatestPaymentItem, lastPaymentOriginatingPlatform: state.proLastPaymentOriginatingPlatform, + originatingAccount: state.proOriginatingAccount, refundingStatus: state.proRefundingStatus ), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } @@ -1237,11 +1256,11 @@ extension SessionProSettingsViewModel { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, dataModel: SessionProPaymentScreenContent.DataModel( flow: .cancel(originatingPlatform: state.proLastPaymentOriginatingPlatform), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } - ) + ), + dependencies: dependencies ) ) ) @@ -1252,15 +1271,15 @@ extension SessionProSettingsViewModel { let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( - dependencies: dependencies, dataModel: SessionProPaymentScreenContent.DataModel( flow: .refund( originatingPlatform: state.proLastPaymentOriginatingPlatform, - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], // TODO: [PRO] Get the real state if not originator + isNonOriginatingAccount: (state.proOriginatingAccount == .nonOriginatingAccount), requestedAt: nil ), plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } - ) + ), + dependencies: dependencies ) ) ) @@ -1355,6 +1374,7 @@ extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { accessExpiryTimestampMs: UInt64?, latestPaymentItem: Network.SessionPro.PaymentItem?, lastPaymentOriginatingPlatform: SessionProUI.ClientPlatform, + originatingAccount: SessionPro.OriginatingAccount, refundingStatus: SessionPro.RefundingStatus ) { let latestPlan: SessionPro.Plan? = plans.first { $0.variant == latestPaymentItem?.plan } @@ -1374,7 +1394,7 @@ extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { case (.active, .some, .refunding): self = .refund( originatingPlatform: lastPaymentOriginatingPlatform, - isNonOriginatingAccount: dependencies[feature: .mockNonOriginatingAccount], // TODO: [PRO] Get the real state if not originator, + isNonOriginatingAccount: (originatingAccount == .nonOriginatingAccount), requestedAt: (latestPaymentItem?.refundRequestedTimestampMs).map { Date(timeIntervalSince1970: (Double($0) / 1000)) } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 7831e7e1bf..5db69cb599 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -355,7 +355,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl trailingImage: { switch state.proStatus { case .none, .neverBeenPro: return nil - case .active, .refunding: + case .active: return SessionProBadge.trailingImage( size: .medium, themeBackgroundColor: .primary @@ -462,7 +462,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .put(key: "app_name", value: Constants.app_name) .localized() - case .active, .refunding: + case .active: return "sessionProBeta" .put(key: "app_pro", value: Constants.app_pro) .localized() diff --git a/Session/Utilities/DonationsManager.swift b/Session/Utilities/DonationsManager.swift index 7f2589d175..ea0a6bb9d5 100644 --- a/Session/Utilities/DonationsManager.swift +++ b/Session/Utilities/DonationsManager.swift @@ -107,7 +107,7 @@ public class DonationsManager { } @MainActor public func openDonationsUrlModal(superPresenter: UIViewController? = nil) -> ConfirmationModal? { - guard let url: URL = URL(string: Constants.session_donations_url) else { return nil } + guard let url: URL = URL(string: Constants.urls.donationsApp) else { return nil } return ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index a632dcaef0..15854ea8dc 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -245,15 +245,10 @@ public extension UIContextualAction { ), dataManager: dependencies[singleton: .imageDataManager], sessionProUIManager: dependencies[singleton: .sessionProManager], + onConfirm: { [dependencies] in + }, afterClosed: { [completionHandler] in completionHandler(true) - }, - onConfirm: { [dependencies] in - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) } ) ) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index d32713a6c5..64707d7e16 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -41,6 +41,7 @@ public actor SessionProManager: SessionProManagerType { nonisolated private let accessExpiryTimestampMsStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let latestPaymentItemStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) nonisolated private let latestPaymentOriginatingPlatformStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.iOS) + nonisolated private let originatingAccountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.originatingAccount) nonisolated private let refundingStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.notRefunding) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } @@ -70,6 +71,7 @@ public actor SessionProManager: SessionProManagerType { nonisolated public var latestPaymentOriginatingPlatform: AsyncStream { latestPaymentOriginatingPlatformStream.stream } + nonisolated public var originatingAccount: AsyncStream { originatingAccountStream.stream } nonisolated public var refundingStatus: AsyncStream { refundingStatusStream.stream } // MARK: - Initialization @@ -253,6 +255,8 @@ public actor SessionProManager: SessionProManagerType { @discardableResult @MainActor public func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { @@ -272,6 +276,8 @@ public actor SessionProManager: SessionProManagerType { dataManager: syncState.dependencies[singleton: .imageDataManager], sessionProUIManager: self, dismissType: dismissType, + onConfirm: onConfirm, + onCancel: onCancel, afterClosed: afterClosed ) ) @@ -657,6 +663,7 @@ public protocol SessionProManagerType: SessionProUIManagerType { nonisolated var accessExpiryTimestampMs: AsyncStream { get } nonisolated var latestPaymentItem: AsyncStream { get } nonisolated var latestPaymentOriginatingPlatform: AsyncStream { get } + nonisolated var originatingAccount: AsyncStream { get } nonisolated var refundingStatus: AsyncStream { get } nonisolated func proStatus( @@ -696,6 +703,13 @@ extension SessionProUI.ClientPlatform { // stringlint:ignore_contents public extension ObservableKey { + static func buildVariant(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "buildVariant", + generic: .buildVariant + ) { [weak manager] in manager?.buildVariant } + } + static func currentUserProLoadingState(_ manager: SessionProManagerType) -> ObservableKey { return ObservableKey.stream( key: "currentUserProLoadingState", @@ -738,6 +752,13 @@ public extension ObservableKey { ) { [weak manager] in manager?.latestPaymentOriginatingPlatform } } + static func currentUserProOriginatingAccount(_ manager: SessionProManagerType) -> ObservableKey { + return ObservableKey.stream( + key: "currentUserProOriginatingAccount", + generic: .currentUserProOriginatingAccount + ) { [weak manager] in manager?.originatingAccount } + } + static func currentUserProRefundingStatus(_ manager: SessionProManagerType) -> ObservableKey { return ObservableKey.stream( key: "currentUserProRefundingStatus", @@ -748,12 +769,14 @@ public extension ObservableKey { // stringlint:ignore_contents public extension GenericObservableKey { + static let buildVariant: GenericObservableKey = "buildVariant" static let currentUserProLoadingState: GenericObservableKey = "currentUserProLoadingState" static let currentUserProStatus: GenericObservableKey = "currentUserProStatus" static let currentUserProAutoRenewing: GenericObservableKey = "currentUserProAutoRenewing" static let currentUserProAccessExpiryTimestampMs: GenericObservableKey = "currentUserProAccessExpiryTimestampMs" static let currentUserProLatestPaymentItem: GenericObservableKey = "currentUserProLatestPaymentItem" static let currentUserLatestPaymentOriginatingPlatform: GenericObservableKey = "currentUserLatestPaymentOriginatingPlatform" + static let currentUserProOriginatingAccount: GenericObservableKey = "currentUserProOriginatingAccount" static let currentUserProRefundingStatus: GenericObservableKey = "currentUserProRefundingStatus" } @@ -816,11 +839,20 @@ private extension SessionProManager { default: break } + switch (state.previousInfo?.mockOriginatingAccount, state.info.mockOriginatingAccount) { + case (.simulate, .useActual): return true + default: break + } + switch (state.previousInfo?.mockRefundingStatus, state.info.mockRefundingStatus) { case (.simulate, .useActual): return true default: break } + if (state.previousInfo?.mockAccessExpiryTimestamp ?? 0) > 0 && state.info.mockAccessExpiryTimestamp == 0 { + return true + } + return false }() @@ -851,7 +883,7 @@ private extension SessionProManager { } if state.info.mockOriginatingPlatform != state.previousInfo?.mockOriginatingPlatform { - switch state.info.mockBuildVariant { + switch state.info.mockOriginatingPlatform { case .useActual: break case .simulate(let value): await self?.latestPaymentOriginatingPlatformStream.send(value) } @@ -864,12 +896,25 @@ private extension SessionProManager { } } + if state.info.mockOriginatingAccount != state.previousInfo?.mockOriginatingAccount { + switch state.info.mockOriginatingAccount { + case .useActual: break + case .simulate(let value): await self?.originatingAccountStream.send(value) + } + } + if state.info.mockRefundingStatus != state.previousInfo?.mockRefundingStatus { switch state.info.mockRefundingStatus { case .useActual: break case .simulate(let value): await self?.refundingStatusStream.send(value) } } + + if state.info.mockAccessExpiryTimestamp != state.previousInfo?.mockAccessExpiryTimestamp { + if state.info.mockAccessExpiryTimestamp > 0 { + await self?.accessExpiryTimestampMsStream.send(UInt64(state.info.mockAccessExpiryTimestamp)) + } + } } } } @@ -901,4 +946,12 @@ private extension SessionProManager { case .useActual: return value } } + + private func mockedIfNeeded(_ value: UInt64?) -> UInt64? { + let mockedValue: TimeInterval = dependencies[feature: .mockCurrentUserAccessExpiryTimestamp] + + guard mockedValue > 0 else { return value } + + return UInt64(mockedValue) + } } diff --git a/SessionMessagingKit/Types/Constants+LibSession.swift b/SessionMessagingKit/Types/Constants+LibSession.swift index 2f26216314..f210b0a8fb 100644 --- a/SessionMessagingKit/Types/Constants+LibSession.swift +++ b/SessionMessagingKit/Types/Constants+LibSession.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit public extension Constants { static let urls: GeneralUrls = GeneralUrls(SESSION_PROTOCOL_STRINGS) - static let buildVariants: BuildVariants = BuildVariants(SESSION_PROTOCOL_STRINGS) + static let buildVariants: BuildVariants = BuildVariants(SESSION_PROTOCOL_STRINGS, PaymentProvider.appStore) enum PaymentProvider { private static let metadata: [session_pro_backend_payment_provider_metadata] = [ @@ -68,15 +68,21 @@ public extension Constants { struct BuildVariants: StringProvider.BuildVariant { public let apk: String + public var appStore: String + public var development: String public let fDroid: String public let huawei: String public let ipa: String + public var testFlight: String - fileprivate init(_ libSessionValue: session_protocol_strings) { + fileprivate init(_ libSessionValue: session_protocol_strings, _ iOSPaymentProvider: PaymentProvider.Info) { self.apk = libSessionValue.get(\.build_variant_apk) + self.appStore = iOSPaymentProvider.store + self.development = "Development" // stringlint:ignore self.fDroid = libSessionValue.get(\.build_variant_fdroid) self.huawei = libSessionValue.get(\.build_variant_huawei) self.ipa = libSessionValue.get(\.build_variant_ipa) + self.testFlight = "TestFlight" // stringlint:ignore } } } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index e5ed8d07e0..b7a7b9fa67 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -22,24 +22,26 @@ public struct ProCTAModal: View { private let sessionProUIManager: SessionProUIManagerType let dismissType: Modal.DismissType - let afterClosed: (() -> Void)? let onConfirm: (() -> Void)? + let onCancel: (() -> Void)? + let afterClosed: (() -> Void)? public init( variant: ProCTAModal.Variant, dataManager: ImageDataManagerType, sessionProUIManager: SessionProUIManagerType, dismissType: Modal.DismissType = .recursive, - afterClosed: (() -> Void)? = nil, - onConfirm: (() -> Void)? = nil - + onConfirm: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil, + afterClosed: (() -> Void)? = nil ) { self.variant = variant self.dataManager = dataManager self.sessionProUIManager = sessionProUIManager self.dismissType = dismissType - self.afterClosed = afterClosed self.onConfirm = onConfirm + self.onCancel = onCancel + self.afterClosed = afterClosed } public var body: some View { @@ -261,6 +263,7 @@ public struct ProCTAModal: View { // Cancel Button Button { + onCancel?() close(nil) } label: { Text(variant.cancelButtonTitle) @@ -455,44 +458,6 @@ public extension ProCTAModal.Variant { } } -// MARK: - SessionProCTAManagerType - -public protocol SessionProCTAManagerType: AnyObject { - var isSessionProPublisher: AnyPublisher { get } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - beforePresented: (() -> Void)?, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool -} - -// MARK: - Convenience - -public extension SessionProCTAManagerType { - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType = .recursive, - onConfirm: (() -> Void)? = nil, - onCancel: (() -> Void)? = nil, - afterClosed: (() -> Void)? = nil, - presenting: ((UIViewController) -> Void)? = nil - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: dismissType, - onConfirm: onConfirm, - onCancel: onCancel, - afterClosed: afterClosed, - presenting: presenting - ) - } -} - // MARK: - Previews struct ProCTAModal_Previews: PreviewProvider { diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index 2cc3f6ca03..08734dbf97 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -39,7 +39,7 @@ public extension SessionProPaymentScreenContent { .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular) - case .update(let currentPlan, let expiredOn, .android, false): + case .update(_, let expiredOn, .android, false): return "proAccessExpireDate" .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) .put(key: "pro", value: Constants.pro) @@ -52,7 +52,7 @@ public extension SessionProPaymentScreenContent { .put(key: "pro", value: Constants.pro) .localizedFormatted(Fonts.Body.baseRegular) - case .update(let currentPlan, let expiredOn, .iOS, false): + case .update(_, let expiredOn, .iOS, false): return "proAccessActivatedNotAuto" .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) .put(key: "pro", value: Constants.pro) diff --git a/SessionUIKit/Types/SessionProUIManagerType.swift b/SessionUIKit/Types/SessionProUIManagerType.swift index e3bd5e8b29..35633a47a8 100644 --- a/SessionUIKit/Types/SessionProUIManagerType.swift +++ b/SessionUIKit/Types/SessionProUIManagerType.swift @@ -14,6 +14,8 @@ public protocol SessionProUIManagerType: Actor { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool @@ -24,28 +26,21 @@ public protocol SessionProUIManagerType: Actor { public extension SessionProUIManagerType { @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? + dismissType: Modal.DismissType = .recursive, + onConfirm: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil, + afterClosed: (() -> Void)? = nil, + presenting: ((UIViewController) -> Void)? = nil ) -> Bool { showSessionProCTAIfNeeded( variant, - dismissType: .recursive, + dismissType: dismissType, + onConfirm: onConfirm, + onCancel: onCancel, afterClosed: afterClosed, presenting: presenting ) } - - @discardableResult @MainActor func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - showSessionProCTAIfNeeded( - variant, - dismissType: .recursive, - afterClosed: nil, - presenting: presenting - ) - } } // MARK: - Noop From 1b8d6ead1e348f8f673663c1e7c38599b52ff854 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 2 Dec 2025 16:22:11 +1100 Subject: [PATCH 30/66] Added in initial AppStore purchase logic, cleaned up some code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added in the actual Session Pro AppStore purchase call • Cleaned up the SessionProManager interface • Fixed an issue where enabling Session Pro wouldn't update the settings screen • Fixed a few issues with performing UI actions on background threads (mentions "@You badge") --- Session.xcodeproj/project.pbxproj | 16 +- Session/Conversations/ConversationVC.swift | 3 + .../Conversations/ConversationViewModel.swift | 17 +- .../Message Cells/VisibleMessageCell.swift | 18 +- Session/Home/HomeViewModel.swift | 64 +- .../MessageInfoScreen.swift | 1 + .../DeveloperSettingsProViewModel.swift | 475 +++------- Session/Settings/NukeDataModal.swift | 4 +- .../SessionProPaymentScreen+ViewModel.swift | 58 +- .../SessionProSettingsViewModel.swift | 359 ++------ Session/Settings/SettingsViewModel.swift | 71 +- .../Views/SessionProBadge+Utilities.swift | 116 +-- .../SessionPro/SessionProManager.swift | 809 ++++++++---------- .../SessionPro/Types/SessionProExpiry.swift | 54 -- .../SessionPro/Types/SessionProPlan.swift | 83 +- .../SessionPro/Types/SessionProState.swift | 352 ++++++++ .../Utilities/SessionPro+Convenience.swift | 86 ++ .../Utilities/SessionProMocking.swift | 156 ---- .../Shared Models/MessageViewModel.swift | 24 +- .../Utilities/Profile+Updating.swift | 4 +- .../SessionPro/SessionProAPI.swift | 3 + .../Modals & Toast/ConfirmationModal.swift | 50 +- SessionUIKit/Components/SessionProBadge.swift | 12 + .../Components/SwiftUI/AttributedText.swift | 2 +- .../SwiftUI/QuoteView_SwiftUI.swift | 18 +- .../SessionListScreen+Models.swift | 10 +- .../SessionListScreen+ListItemCell.swift | 16 +- .../SessionProPaymentScreen+Models.swift | 10 +- .../SessionProPaymentScreen+Purchase.swift | 11 +- .../SessionProPaymentScreen.swift | 101 +-- SessionUIKit/Style Guide/ThemeManager.swift | 7 +- .../Themes/ThemedAttributedString.swift | 109 +-- .../Style Guide/Themes/UIKit+Theme.swift | 4 +- SessionUIKit/Types/BuildVariant.swift | 2 +- .../Types/SessionProUIManagerType.swift | 9 +- SessionUIKit/Utilities/MentionUtilities.swift | 61 +- .../Utilities/UILabel+Utilities.swift | 22 +- SessionUIKit/Utilities/UIView+Utilities.swift | 2 +- 38 files changed, 1474 insertions(+), 1745 deletions(-) delete mode 100644 SessionMessagingKit/SessionPro/Types/SessionProExpiry.swift create mode 100644 SessionMessagingKit/SessionPro/Types/SessionProState.swift create mode 100644 SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift delete mode 100644 SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 96eaf2e046..61eaa3035d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -516,7 +516,7 @@ FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */; }; - FD1F3CF62ED69B6600E536D5 /* SessionProMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */; }; + FD1F3CF62ED69B6600E536D5 /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */; }; FD1F3CF82ED6A6F400E536D5 /* SessionProRefundingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */; }; FD1F3CFA2ED7B34C00E536D5 /* SessionProMessageFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */; }; FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */; }; @@ -623,6 +623,7 @@ FD2C68612EA09527000B0E37 /* MessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2C68602EA09523000B0E37 /* MessageError.swift */; }; FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */; }; FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */; }; + FD2CFB972EDE645D00EC7F98 /* SessionPro+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */; }; FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; @@ -680,7 +681,6 @@ FD360ECB2ECD59550050CAF4 /* DonationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */; }; FD360ECD2ECD70590050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */; }; FD360ECF2ECEE5F60050CAF4 /* SessionProLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */; }; - FD360ED12ECFB8AC0050CAF4 /* SessionProExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */; }; FD360ED42ED035150050CAF4 /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FD360ED62ED3D2280050CAF4 /* ObservationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */; }; FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */; }; @@ -2036,7 +2036,7 @@ FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPaymentRefundRequestedResponse.swift; sourceTree = ""; }; FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProUI.swift; sourceTree = ""; }; FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+LibSession.swift"; sourceTree = ""; }; - FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMocking.swift; sourceTree = ""; }; + FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProRefundingStatus.swift; sourceTree = ""; }; FD1F3CF92ED7B34700E536D5 /* SessionProMessageFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProMessageFeatures.swift; sourceTree = ""; }; FD1F3CFB2ED7F37300E536D5 /* StringProviders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringProviders.swift; sourceTree = ""; }; @@ -2111,6 +2111,7 @@ FD2C68602EA09523000B0E37 /* MessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageError.swift; sourceTree = ""; }; FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProOriginatingAccount.swift; sourceTree = ""; }; FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildVariant.swift; sourceTree = ""; }; + FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPro+Convenience.swift"; sourceTree = ""; }; FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsRequest.swift; sourceTree = ""; }; FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsResponse.swift; sourceTree = ""; }; @@ -2141,7 +2142,6 @@ FD360ECA2ECD59520050CAF4 /* DonationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationsManager.swift; sourceTree = ""; }; FD360ECC2ECD70510050CAF4 /* DeveloperSettingsModalsAndBannersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsModalsAndBannersViewModel.swift; sourceTree = ""; }; FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProLoadingState.swift; sourceTree = ""; }; - FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProExpiry.swift; sourceTree = ""; }; FD360ED52ED3D2250050CAF4 /* ObservationUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationUtilities.swift; sourceTree = ""; }; FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPlan.swift; sourceTree = ""; }; FD360ED92ED3E8BC0050CAF4 /* DonationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = DonationsCTA.webp; sourceTree = ""; }; @@ -4508,7 +4508,7 @@ FD1F3CF42ED69B5B00E536D5 /* Utilities */ = { isa = PBXGroup; children = ( - FD1F3CF52ED69B6200E536D5 /* SessionProMocking.swift */, + FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */, ); path = Utilities; sourceTree = ""; @@ -5357,7 +5357,6 @@ FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, - FD360ED02ECFB8A70050CAF4 /* SessionProExpiry.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, @@ -5366,6 +5365,7 @@ FD360ED72ED3E5BF0050CAF4 /* SessionProPlan.swift */, FDAA36C92EB476060040603E /* SessionProProfileFeatures.swift */, FD1F3CF72ED6A6EB00E536D5 /* SessionProRefundingStatus.swift */, + FD1F3CF52ED69B6200E536D5 /* SessionProState.swift */, ); path = Types; sourceTree = ""; @@ -7264,7 +7264,6 @@ FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, FD2272FD2C352D8E004D8A6C /* LibSession+ConvoInfoVolatile.swift in Sources */, - FD360ED12ECFB8AC0050CAF4 /* SessionProExpiry.swift in Sources */, FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, @@ -7316,7 +7315,7 @@ FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, - FD1F3CF62ED69B6600E536D5 /* SessionProMocking.swift in Sources */, + FD1F3CF62ED69B6600E536D5 /* SessionProState.swift in Sources */, FDD23AEA2E458EB00057E853 /* _012_AddJobPriority.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, @@ -7420,6 +7419,7 @@ FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, FD1F3CF32ED657AC00E536D5 /* Constants+LibSession.swift in Sources */, + FD2CFB972EDE645D00EC7F98 /* SessionPro+Convenience.swift in Sources */, FDFE75B12ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift in Sources */, FD09799B27FFC82D00936362 /* Quote.swift in Sources */, FD2273012C352D8E004D8A6C /* LibSession+Shared.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b478f05b82..b5a5cbaad4 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -483,6 +483,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.viewModel = ConversationViewModel( threadViewModel: threadViewModel, focusedInteractionInfo: focusedInteractionInfo, + currentUserMentionImage: MentionUtilities.generateCurrentUserMentionImage( + textColor: MessageViewModel.bodyTextColor(isOutgoing: false) /// Outgoing messages don't use the image + ), using: dependencies ) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 5b35ff8098..7638142eac 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -91,12 +91,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold @MainActor init( threadViewModel: SessionThreadViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, + currentUserMentionImage: UIImage, using dependencies: Dependencies ) { self.dependencies = dependencies self.state = State.initialState( threadViewModel: threadViewModel, focusedInteractionInfo: focusedInteractionInfo, + currentUserMentionImage: currentUserMentionImage, using: dependencies ) @@ -138,6 +140,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let threadVariant: SessionThread.Variant let userSessionId: SessionId let currentUserSessionIds: Set + let currentUserMentionImage: UIImage let isBlindedContact: Bool let wasPreviouslyBlindedContact: Bool @@ -315,6 +318,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold static func initialState( threadViewModel: SessionThreadViewModel, focusedInteractionInfo: Interaction.TimestampInfo?, + currentUserMentionImage: UIImage, using dependencies: Dependencies ) -> State { let userSessionId: SessionId = dependencies[cache: .general].sessionId @@ -325,6 +329,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadVariant: threadViewModel.threadVariant, userSessionId: userSessionId, currentUserSessionIds: [userSessionId.hexString], + currentUserMentionImage: currentUserMentionImage, isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), focusedInteractionInfo: focusedInteractionInfo, @@ -1166,6 +1171,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .first(where: { currentUserSessionIds.contains($0.authorId) })? .id ), + currentUserMentionImage: previousState.currentUserMentionImage, using: dependencies ) } @@ -1176,6 +1182,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadVariant: threadVariant, userSessionId: previousState.userSessionId, currentUserSessionIds: currentUserSessionIds, + currentUserMentionImage: previousState.currentUserMentionImage, isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadId), wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(previousState.threadId), focusedInteractionInfo: focusedInteractionInfo, @@ -1300,10 +1307,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold case .exceedsCharacterLimit: throw MessageError.messageTooLarge } }() - let proProfileFeatures: SessionPro.ProfileFeatures = ( - dependencies[singleton: .sessionProManager].currentUserCurrentProProfileFeatures ?? - .none - ) + let proProfileFeatures: SessionPro.ProfileFeatures = dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .profileFeatures let interaction: Interaction = Interaction( threadId: currentState.threadId, threadVariant: currentState.threadVariant, @@ -1424,7 +1430,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold profileCache[sessionId]?.displayName( includeSessionIdSuffix: (viewModel.threadVariant == .community && inMessageBody) ) - } + }, + currentUserMentionImage: viewModel.currentUserMentionImage ) } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 2563ed088a..fd48ed0f0a 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -493,10 +493,7 @@ final class VisibleMessageCell: MessageCell { displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { - let bodyLabelTextColor: ThemeValue = (cellViewModel.variant.isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) + let bodyLabelTextColor: ThemeValue = cellViewModel.bodyTextColor snContentView.alignment = (cellViewModel.variant.isOutgoing ? .trailing : .leading) for subview in snContentView.arrangedSubviews { @@ -1299,7 +1296,8 @@ final class VisibleMessageCell: MessageCell { .themeForegroundColor: textColor, .font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) ], - displayNameRetriever: displayNameRetriever + displayNameRetriever: displayNameRetriever, + currentUserMentionImage: cellViewModel.currentUserMentionImage ) // Custom handle links @@ -1312,23 +1310,25 @@ final class VisibleMessageCell: MessageCell { // NSAttributedString and NSRange are both based on UTF-16 encoded lengths, so // in order to avoid strings which contain emojis breaking strings which end // with URLs we need to use the 'String.utf16.count' value when creating the range + let rawString: String = attributedText.string + return detector .matches( - in: attributedText.string, + in: rawString, options: [], - range: NSRange(location: 0, length: attributedText.string.utf16.count) + range: NSRange(location: 0, length: rawString.utf16.count) ) .reduce(into: [:]) { result, match in guard let matchUrl: URL = match.url, - let originalRange: Range = Range(match.range, in: attributedText.string) + let originalRange: Range = Range(match.range, in: rawString) else { return } /// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and /// set the scheme to 'https' instead as we don't load previews for 'http' so this will result /// in more previews actually getting loaded without forcing the user to enter 'https://' before /// every URL they enter - let originalString: String = String(attributedText.string[originalRange]) + let originalString: String = String(rawString[originalRange]) guard matchUrl.absoluteString != "http://\(originalString)" else { guard let httpsUrl: URL = URL(string: "https://\(originalString)") else { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index b1f737bf91..7e2cddc54c 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -664,75 +664,21 @@ public class HomeViewModel: NavigatableStateHolder { } @MainActor func showSessionProCTAIfNeeded() async { - let status: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager].proStatus.first(defaultValue: nil) - let refundingStatus: SessionPro.RefundingStatus = await dependencies[singleton: .sessionProManager].refundingStatus.first(defaultValue: .notRefunding) - let variant: ProCTAModal.Variant - - switch (status, refundingStatus) { - case (.none, _), (.neverBeenPro, _), (.active, .refunding): return - - case (.active, .notRefunding): - let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] - .accessExpiryTimestampMs - .first() - .map { value in value.map { Date(timeIntervalSince1970: (Double($0) / 1000)) } } - .map { $0.timeIntervalSince(dependencies.dateNow) } ?? 0) - guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return } - - variant = .expiring( - timeLeft: expiryInSeconds.formatted( - format: .long, - allowedUnits: [ .day, .hour, .minute ] - ) - ) - - case (.expired, _): - let expiryInSeconds: TimeInterval = (await dependencies[singleton: .sessionProManager] - .accessExpiryTimestampMs - .first() - .map { value in value.map { Date(timeIntervalSince1970: (Double($0) / 1000)) } } - .map { $0.timeIntervalSince(dependencies.dateNow) } ?? 0) - - guard expiryInSeconds <= 30 * 24 * 60 * 60 else { return } - - variant = .expiring(timeLeft: nil) + guard let info = await dependencies[singleton: .sessionProManager].sessionProExpiringCTAInfo() else { + return } - guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return } - try? await Task.sleep(for: .seconds(1)) /// Cooperative suspension, so safe to call on main thread - let plans: [SessionPro.Plan] = await dependencies[singleton: .sessionProManager].plans - let proStatus: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager].proStatus - .first(defaultValue: nil) - let proAutoRenewing: Bool? = await dependencies[singleton: .sessionProManager].autoRenewing - .first(defaultValue: nil) - let proAccessExpiryTimestampMs: UInt64? = await dependencies[singleton: .sessionProManager].accessExpiryTimestampMs - .first(defaultValue: nil) - let proLatestPaymentItem: Network.SessionPro.PaymentItem? = await dependencies[singleton: .sessionProManager].latestPaymentItem - .first(defaultValue: nil) - let proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform = await dependencies[singleton: .sessionProManager].latestPaymentOriginatingPlatform - .first(defaultValue: .iOS) - let proRefundingStatus: SessionPro.RefundingStatus = await dependencies[singleton: .sessionProManager].refundingStatus - .first(defaultValue: .notRefunding) - dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - variant, + info.variant, onConfirm: { [weak self, dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dataModel: SessionProPaymentScreenContent.DataModel( - flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow( - plans: plans, - proStatus: proStatus, - autoRenewing: proAutoRenewing, - accessExpiryTimestampMs: proAccessExpiryTimestampMs, - latestPaymentItem: proLatestPaymentItem, - lastPaymentOriginatingPlatform: proLastPaymentOriginatingPlatform, - refundingStatus: proRefundingStatus - ), - plans: plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + flow: info.paymentFlow, + plans: info.planInfo ), dependencies: dependencies ) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 61b7300907..7d5238572f 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -883,6 +883,7 @@ struct MessageInfoView_Previews: PreviewProvider { nextInteraction: nil, isLast: true, isLastOutgoing: false, + currentUserMentionImage: nil, using: dependencies ) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 3084fe19a7..7b1a553ccb 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -72,10 +72,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public enum TableItem: Hashable, Differentiable, CaseIterable { case enableSessionPro + case mockCurrentUserSessionProBuildVariant case mockCurrentUserSessionProBackendStatus case mockCurrentUserSessionProLoadingState case mockCurrentUserSessionProOriginatingPlatform - case mockCurrentUserNonOriginatingAccount + case mockCurrentUserOriginatingAccount + case mockCurrentUserAccessExpiryTimestamp case proBadgeEverywhere case fakeAppleSubscriptionForDev @@ -84,11 +86,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case forceMessageFeatureAnimatedAvatar case proPlanToRecover - case proPlanExpiry - case proPlanExpiredOverThirtyDays - case mockInstalledFromIPA - case originatingPlatform - case nonOriginatingAccount case purchaseProSubscription case manageProSubscriptions @@ -107,10 +104,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold switch self { case .enableSessionPro: return "enableSessionPro" + case .mockCurrentUserSessionProBuildVariant: return "mockCurrentUserSessionProBuildVariant" case .mockCurrentUserSessionProBackendStatus: return "mockCurrentUserSessionProBackendStatus" case .mockCurrentUserSessionProLoadingState: return "mockCurrentUserSessionProLoadingState" case .mockCurrentUserSessionProOriginatingPlatform: return "mockCurrentUserSessionProOriginatingPlatform" - case .mockCurrentUserNonOriginatingAccount: return "mockCurrentUserNonOriginatingAccount" + case .mockCurrentUserOriginatingAccount: return "mockCurrentUserOriginatingAccount" + case .mockCurrentUserAccessExpiryTimestamp: return "mockCurrentUserAccessExpiryTimestamp" case .proBadgeEverywhere: return "proBadgeEverywhere" case .fakeAppleSubscriptionForDev: return "fakeAppleSubscriptionForDev" @@ -119,10 +118,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .forceMessageFeatureAnimatedAvatar: return "forceMessageFeatureAnimatedAvatar" case .proPlanToRecover: return "proPlanToRecover" - case .proPlanExpiry: return "proPlanExpiry" - case .proPlanExpiredOverThirtyDays: return "proPlanExpiredOverThirtyDays" - case .mockInstalledFromIPA: return "mockInstalledFromIPA" - case .originatingPlatform: return "originatingPlatform" case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" @@ -143,11 +138,13 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var result: [TableItem] = [] switch TableItem.enableSessionPro { case .enableSessionPro: result.append(.enableSessionPro); fallthrough - + + case .mockCurrentUserSessionProBuildVariant: result.append(.mockCurrentUserSessionProBuildVariant); fallthrough case .mockCurrentUserSessionProBackendStatus: result.append(.mockCurrentUserSessionProBackendStatus); fallthrough case .mockCurrentUserSessionProLoadingState: result.append(.mockCurrentUserSessionProLoadingState); fallthrough case .mockCurrentUserSessionProOriginatingPlatform: result.append(.mockCurrentUserSessionProOriginatingPlatform); fallthrough - case .mockCurrentUserNonOriginatingAccount: result.append(.mockCurrentUserNonOriginatingAccount); fallthrough + case .mockCurrentUserAccessExpiryTimestamp: result.append(.mockCurrentUserAccessExpiryTimestamp); fallthrough + case .mockCurrentUserOriginatingAccount: result.append(.mockCurrentUserOriginatingAccount); fallthrough case .proBadgeEverywhere: result.append(.proBadgeEverywhere); fallthrough case .fakeAppleSubscriptionForDev: result.append(.fakeAppleSubscriptionForDev); fallthrough @@ -156,12 +153,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .forceMessageFeatureAnimatedAvatar: result.append(.forceMessageFeatureAnimatedAvatar); fallthrough case .proPlanToRecover: result.append(.proPlanToRecover); fallthrough - case .proPlanExpiry: result.append(.proPlanExpiry); fallthrough - - // TODO: Probably need to add this one -// case .proPlanExpiredOverThirtyDays: result.append(.proPlanExpiredOverThirtyDays); fallthrough -// case .mockInstalledFromIPA: result.append(mockInstalledFromIPA); fallthrough - case .originatingPlatform: result.append(.originatingPlatform); fallthrough case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough @@ -189,10 +180,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public struct State: Equatable, ObservableKeyProvider { let sessionProEnabled: Bool + let mockCurrentUserSessionProBuildVariant: MockableFeature let mockCurrentUserSessionProBackendStatus: MockableFeature let mockCurrentUserSessionProLoadingState: MockableFeature let mockCurrentUserSessionProOriginatingPlatform: MockableFeature - let mockCurrentUserNonOriginatingAccount: Bool + let mockCurrentUserOriginatingAccount: MockableFeature + let mockCurrentUserAccessExpiryTimestamp: TimeInterval let proBadgeEverywhere: Bool let fakeAppleSubscriptionForDev: Bool @@ -201,10 +194,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let forceMessageFeatureAnimatedAvatar: Bool // TODO: [PRO] Add these back // let proPlanToRecover: Bool -// let proPlanExpiry: SessionProStateExpiryMock -// let mockInstalledFromIPA: Bool -// let originatingPlatform: ClientPlatform -// let proPlanExpiredOverThirtyDays: Bool let products: [Product] let purchasedProduct: Product? @@ -229,10 +218,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold public let observedKeys: Set = [ .feature(.sessionProEnabled), + .feature(.mockCurrentUserSessionProBuildVariant), .feature(.mockCurrentUserSessionProBackendStatus), .feature(.mockCurrentUserSessionProLoadingState), .feature(.mockCurrentUserSessionProOriginatingPlatform), - .feature(.mockCurrentUserNonOriginatingAccount), + .feature(.mockCurrentUserOriginatingAccount), + .feature(.mockCurrentUserAccessExpiryTimestamp), .feature(.proBadgeEverywhere), .feature(.fakeAppleSubscriptionForDev), // .feature(.proPlanToRecover), @@ -249,17 +240,16 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], - mockCurrentUserNonOriginatingAccount: dependencies[feature: .mockCurrentUserNonOriginatingAccount], + mockCurrentUserOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockCurrentUserAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], // proPlanToRecover: dependencies[feature: .proPlanToRecover], -// proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], -// proPlanExpiredOverThirtyDays: dependencies[feature: .mockExpiredOverThirtyDays], -// mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], @@ -327,16 +317,15 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockCurrentUserSessionProBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], mockCurrentUserSessionProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], mockCurrentUserSessionProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], mockCurrentUserSessionProOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], - mockCurrentUserNonOriginatingAccount: dependencies[feature: .mockCurrentUserNonOriginatingAccount], + mockCurrentUserOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockCurrentUserAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], proBadgeEverywhere: dependencies[feature: .proBadgeEverywhere], fakeAppleSubscriptionForDev: dependencies[feature: .fakeAppleSubscriptionForDev], // proPlanToRecover: dependencies[feature: .proPlanToRecover], -// proPlanExpiry: dependencies[feature: .mockCurrentUserSessionProExpiry], -// proPlanExpiredOverThirtyDays: dependencies[feature: .mockExpiredOverThirtyDays], -// mockInstalledFromIPA: dependencies[feature: .mockInstalledFromIPA], forceMessageFeatureProBadge: dependencies[feature: .forceMessageFeatureProBadge], forceMessageFeatureLongMessage: dependencies[feature: .forceMessageFeatureLongMessage], forceMessageFeatureAnimatedAvatar: dependencies[feature: .forceMessageFeatureAnimatedAvatar], @@ -386,6 +375,31 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let features: SectionModel = SectionModel( model: .features, elements: [ + SessionCell.Info( + id: .mockCurrentUserSessionProBuildVariant, + title: "Mocked Build Variant", + subtitle: """ + Force the app to be a specific build variant. + + Current: \(devValue: state.mockCurrentUserSessionProBuildVariant) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Build Variant", + explanation: "Force the app to be a specific build variant.", + feature: .mockCurrentUserSessionProBuildVariant, + currentValue: state.mockCurrentUserSessionProBuildVariant, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), SessionCell.Info( id: .mockCurrentUserSessionProBackendStatus, title: "Mocked Pro Status", @@ -477,24 +491,58 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } ), - // TODO: [PRO] Add this in -// ( -// state.originatingPlatform != .iOS ? nil : -// SessionCell.Info( -// id: .nonOriginatingAccount, -// title: "Non-Originating Apple ID", -// trailingAccessory: .toggle( -// state.nonOriginatingAccount, -// oldValue: previousState.nonOriginatingAccount -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .mockNonOriginatingAccount, -// to: !state.nonOriginatingAccount -// ) -// } -// ) -// ), + SessionCell.Info( + id: .mockCurrentUserOriginatingAccount, + title: "Mocked Originating Account", + subtitle: """ + Force the current users Session Pro to have originated from a specific account. + + Current: \(devValue: state.mockCurrentUserOriginatingAccount) + + Note: This option will only be available if the users pro state has been mocked, there is already a mocked loading state, or the users pro state has been fetched via the "Refresh Pro State" action on this screen. + """, + trailingAccessory: .icon(.squarePen), + isEnabled: { + switch (state.mockCurrentUserSessionProLoadingState, state.mockCurrentUserSessionProBackendStatus, state.currentProStatus) { + case (.simulate, _, _), (_, .simulate, _), (_, _, .some): return true + default: return false + } + }(), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableState( + title: "Mocked Originating Account", + explanation: "Force the current users Session Pro to have originated from a specific account.", + feature: .mockCurrentUserOriginatingAccount, + currentValue: state.mockCurrentUserOriginatingAccount, + navigatableStateHolder: viewModel, + onMockingRemoved: { [dependencies] in + Task.detached(priority: .userInitiated) { [dependencies] in + try? await dependencies[singleton: .sessionProManager].refreshProState() + } + }, + using: viewModel?.dependencies + ) + } + ), + SessionCell.Info( + id: .mockCurrentUserAccessExpiryTimestamp, + title: "Mocked Access Expiry Date/Time", + subtitle: """ + Specify a custom date/time that the users Session Pro should expire. + + Current: \(devValue: viewModel.dependencies[feature: .mockCurrentUserAccessExpiryTimestamp]) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + DeveloperSettingsViewModel.showModalForMockableDate( + title: "Mocked Access Expiry Date/Time", + explanation: "The custom date/time the users Session Pro should expire.", + feature: .mockCurrentUserAccessExpiryTimestamp, + navigatableStateHolder: viewModel, + using: dependencies + ) + } + ), SessionCell.Info( id: .proBadgeEverywhere, title: "Show the Pro Badge everywhere", @@ -730,206 +778,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ] ) -// let features: SectionModel = SectionModel( -// model: .features, -// elements: [ -// SessionCell.Info( -// id: .proStatus, -// title: "Pro Status", -// subtitle: """ -// Mock current user a Session Pro user locally. -// """, -// trailingAccessory: .dropDown { state.mockCurrentUserSessionPro.title }, -// onTap: { [weak viewModel, dependencies = viewModel.dependencies] in -// viewModel?.transitionToScreen( -// SessionTableViewController( -// viewModel: SessionListViewModel( -// title: "Session Pro State", -// options: SessionProStateMock.allCases, -// behaviour: .autoDismiss( -// initialSelection: state.mockCurrentUserSessionPro, -// onOptionSelected: viewModel?.updateSessionProState -// ), -// using: dependencies -// ) -// ) -// ) -// } -// ), -// SessionCell.Info( -// id: .allUsersSessionPro, -// title: "Everyone is a Pro", -// subtitle: """ -// Treat all incoming messages as Pro messages. -// Treat all contacts, groups as Session Pro. -// """, -// trailingAccessory: .toggle( -// state.allUsersSessionPro, -// oldValue: previousState.allUsersSessionPro -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .allUsersSessionPro, -// to: !state.allUsersSessionPro -// ) -// } -// ) -// ].appending( -// contentsOf: !state.allUsersSessionPro ? [] : [ -// SessionCell.Info( -// id: .messageFeatureProBadge, -// title: .init("Message Feature: Pro Badge", font: .subtitle), -// trailingAccessory: .toggle( -// state.messageFeatureProBadge, -// oldValue: previousState.messageFeatureProBadge -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .messageFeatureProBadge, -// to: !state.messageFeatureProBadge -// ) -// } -// ), -// SessionCell.Info( -// id: .messageFeatureLongMessage, -// title: .init("Message Feature: Long Message", font: .subtitle), -// trailingAccessory: .toggle( -// state.messageFeatureLongMessage, -// oldValue: previousState.messageFeatureLongMessage -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .messageFeatureLongMessage, -// to: !state.messageFeatureLongMessage -// ) -// } -// ), -// SessionCell.Info( -// id: .messageFeatureAnimatedAvatar, -// title: .init("Message Feature: Animated Avatar", font: .subtitle), -// trailingAccessory: .toggle( -// state.messageFeatureAnimatedAvatar, -// oldValue: previousState.messageFeatureAnimatedAvatar -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .messageFeatureAnimatedAvatar, -// to: !state.messageFeatureAnimatedAvatar -// ) -// } -// ) -// ] -// ).appending( -// contentsOf: [ -// { -// switch state.mockCurrentUserSessionPro { -// case .none: -// SessionCell.Info( -// id: .proPlanToRecover, -// title: "Pro plan to recover", -// subtitle: """ -// Mock a pro plan to recover for pro state `None` and `Expired`. -// """, -// trailingAccessory: .toggle( -// state.proPlanToRecover, -// oldValue: previousState.proPlanToRecover -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .proPlanToRecover, -// to: !state.proPlanToRecover -// ) -// } -// ) -// case .expired: -// SessionCell.Info( -// id: .proPlanExpiredOverThirtyDays, -// title: "Expired over 30 days", -// subtitle: """ -// Mock pro plan expired over 30 days, so the Expired CTA shouldn't show. -// """, -// trailingAccessory: .toggle( -// state.proPlanExpiredOverThirtyDays, -// oldValue: previousState.proPlanExpiredOverThirtyDays -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .mockExpiredOverThirtyDays, -// to: !state.proPlanExpiredOverThirtyDays -// ) -// } -// ) -// case .active, .expiring: -// SessionCell.Info( -// id: .proPlanExpiry, -// title: "Pro plan expiry", -// subtitle: """ -// Mock current pro plan expiry. -// """, -// trailingAccessory: .dropDown { state.proPlanExpiry.title }, -// onTap: { [weak viewModel, dependencies = viewModel.dependencies] in -// viewModel?.transitionToScreen( -// SessionTableViewController( -// viewModel: SessionListViewModel( -// title: "Session Pro Plan Expiry", -// options: SessionProStateExpiryMock.allCases, -// behaviour: .autoDismiss( -// initialSelection: state.proPlanExpiry, -// onOptionSelected: viewModel?.updateSessionProExpiry -// ), -// using: dependencies -// ) -// ) -// ) -// } -// ) -// default: nil -// } -// }(), -// ( -// state.mockCurrentUserSessionPro == .none ? nil : -// SessionCell.Info( -// id: .originatingPlatform, -// title: "Originating Platform", -// trailingAccessory: .dropDown { state.originatingPlatform.title }, -// onTap: { [dependencies = viewModel.dependencies] in -// let newValue: ClientPlatform = { -// switch state.originatingPlatform { -// case .Android: return .iOS -// case .iOS: return .Android -// } -// }() -// -// dependencies.set( -// feature: .proPlanOriginatingPlatform, -// to: newValue -// ) -// dependencies[singleton: .sessionProState].updateOriginatingPlatform(newValue) -// } -// ) -// ), -// SessionCell.Info( -// id: .mockInstalledFromIPA, -// title: "Mock installed from IPA", -// subtitle: """ -// Mock current app is installed from IPA, -// which means NO billing access. -// """, -// trailingAccessory: .toggle( -// state.mockInstalledFromIPA, -// oldValue: previousState.mockInstalledFromIPA -// ), -// onTap: { [dependencies = viewModel.dependencies] in -// dependencies.set( -// feature: .mockInstalledFromIPA, -// to: !state.mockInstalledFromIPA -// ) -// } -// ) -// ] -// ) -// .compactMap { $0 } - return [general, features, subscriptions, proBackend] } @@ -985,120 +833,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } } - -// private func showMockProStatusModal(currentStatus: Network.SessionPro.BackendUserProStatus?) { -// self.transitionToScreen( -// ConfirmationModal( -// info: ConfirmationModal.Info( -// title: "Mocked Pro Status", -// body: .radio( -// explanation: ThemedAttributedString( -// string: "Force the current users Session Pro to a specific status locally." -// ), -// warning: nil, -// options: { -// return ([nil] + Network.SessionPro.BackendUserProStatus.allCases).map { status in -// ConfirmationModal.Info.Body.RadioOptionInfo( -// title: status.title, -// descriptionText: status.subtitle.map { -// ThemedAttributedString( -// stringWithHTMLTags: $0, -// font: RadioButton.descriptionFont -// ) -// }, -// enabled: true, -// selected: currentStatus == status -// ) -// } -// }() -// ), -// confirmTitle: "select".localized(), -// cancelStyle: .alert_text, -// onConfirm: { [dependencies] modal in -// let selectedStatus: Network.SessionPro.BackendUserProStatus? = { -// switch modal.info.body { -// case .radio(_, _, let options): -// return options -// .enumerated() -// .first(where: { _, value in value.selected }) -// .map { index, _ in -// let targetIndex: Int = (index - 1) -// -// guard targetIndex >= 0 && (targetIndex - 1) < Network.SessionPro.BackendUserProStatus.allCases.count else { -// return nil -// } -// -// return Network.SessionPro.BackendUserProStatus.allCases[targetIndex] -// } -// -// default: return nil -// } -// }() -// -// dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: selectedStatus) -// } -// ) -// ), -// transitionType: .present -// ) -// } -// -// private func showMockLoadingStateModal(currentState: SessionProLoadingState?) { -// self.transitionToScreen( -// ConfirmationModal( -// info: ConfirmationModal.Info( -// title: "Mocked Loading State", -// body: .radio( -// explanation: ThemedAttributedString( -// string: "Force the Session Pro UI into a specific loading state." -// ), -// warning: nil, -// options: { -// return ([nil] + SessionProLoadingState.allCases).map { status in -// ConfirmationModal.Info.Body.RadioOptionInfo( -// title: status.title, -// descriptionText: status.subtitle.map { -// ThemedAttributedString( -// stringWithHTMLTags: $0, -// font: RadioButton.descriptionFont -// ) -// }, -// enabled: true, -// selected: currentStatus == status -// ) -// } -// }() -// ), -// confirmTitle: "select".localized(), -// cancelStyle: .alert_text, -// onConfirm: { [dependencies] modal in -// let selectedState: SessionProLoadingState? = { -// switch modal.info.body { -// case .radio(_, _, let options): -// return options -// .enumerated() -// .first(where: { _, value in value.selected }) -// .map { index, _ in -// let targetIndex: Int = (index - 1) -// -// guard targetIndex >= 0 && (targetIndex - 1) < SessionProLoadingState.allCases.count else { -// return nil -// } -// -// return SessionProLoadingState.allCases[targetIndex] -// } -// -// default: return nil -// } -// }() -// -// dependencies.set(feature: .mockCurrentUserSessionProLoadingState, to: selectedState) -// } -// ) -// ), -// transitionType: .present -// ) -// } // // private func updateSessionProState(to state: SessionProStateMock) { // dependencies.set(feature: .mockCurrentUserSessionProState, to: state) @@ -1123,11 +857,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold // case .refunding: // dependencies[singleton: .sessionProState].requestRefund(completion: nil) // } -// } -// -// private func updateSessionProExpiry(to expiry: SessionProStateExpiryMock) { -// dependencies.set(feature: .mockCurrentUserSessionProExpiry, to: expiry) -// dependencies[singleton: .sessionProState].updateProExpiry(expiry.durationInSeconds) // } // MARK: - Pro Requests @@ -1322,13 +1051,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func refreshProState() async { do { try await dependencies[singleton: .sessionProManager].refreshProState() - let status: Network.SessionPro.BackendUserProStatus? = await dependencies[singleton: .sessionProManager] - .proStatus - .first(defaultValue: nil) + let state: SessionPro.State = dependencies[singleton: .sessionProManager].currentUserCurrentProState dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.currentProStatus("\(status.map { "\($0)" } ?? "Unknown")", false) + value: DeveloperSettingsProEvent.currentProStatus("\(state.status)", false) ) } catch { diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 205adb62f5..9ee0a9620e 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -144,7 +144,7 @@ final class NukeDataModal: Modal { title: "clearDataAll".localized(), body: .attributedText( { - switch dependencies[singleton: .sessionProManager].currentUserCurrentProStatus { + switch dependencies[singleton: .sessionProManager].currentUserCurrentProState.status { case .active: "proClearAllDataNetwork" .put(key: "app_pro", value: Constants.app_pro) @@ -169,7 +169,7 @@ final class NukeDataModal: Modal { } private func clearDeviceOnly() { - switch dependencies[singleton: .sessionProManager].currentUserCurrentProStatus { + switch dependencies[singleton: .sessionProManager].currentUserCurrentProState.status { case .active: let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift b/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift index b0743f0e5c..1cc2eecbad 100644 --- a/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProPaymentScreen+ViewModel.swift @@ -8,6 +8,7 @@ import SessionUtilitiesKit extension SessionProPaymentScreenContent { public class ViewModel: ViewModelType { public var dataModel: DataModel + public var dateNow: Date { dependencies.dateNow } public var isRefreshing: Bool = false public var errorString: String? @@ -18,35 +19,52 @@ extension SessionProPaymentScreenContent { self.dataModel = dataModel } - public func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) { - let plan: SessionProPlan = SessionProPlan.from(planInfo) - dependencies[singleton: .sessionProState].upgradeToPro( - plan: plan, - originatingPlatform: .iOS - ) { result in - if result { + @MainActor public func purchase( + planInfo: SessionProPlanInfo, + success: (@MainActor () -> Void)?, + failure: (@MainActor () -> Void)? + ) { + Task(priority: .userInitiated) { + do { + try await dependencies[singleton: .sessionProManager].purchasePro( + productId: planInfo.id + ) success?() - } else { + } + catch { failure?() } } } - public func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) { - dependencies[singleton: .sessionProState].cancelPro { result in - if result { - success?() - } else { - failure?() - } - } + @MainActor public func cancelPro( + success: (@MainActor () -> Void)?, + failure: (@MainActor () -> Void)? + ) { + // TODO: [PRO] Need to add this in +// dependencies[singleton: .sessionProState].cancelPro { result in +// if result { +// success?() +// } else { +// failure?() +// } +// } } - public func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) { - dependencies[singleton: .sessionProState].requestRefund { result in - if result { + @MainActor public func requestRefund( + success: (@MainActor () -> Void)?, + failure: (@MainActor () -> Void)? + ) { + guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + } + + Task(priority: .userInitiated) { + do { + try await dependencies[singleton: .sessionProManager].requestRefund(scene: scene) success?() - } else { + } + catch { failure?() } } diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index a33d3e3c04..9dc4ad8f72 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -129,20 +129,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType public struct State: ObservableKeyProvider { let profile: Profile - let buildVariant: BuildVariant - let loadingState: SessionPro.LoadingState + let proState: SessionPro.State let numberOfGroupsUpgraded: Int let numberOfPinnedConversations: Int let numberOfProBadgesSent: Int let numberOfLongerMessagesSent: Int - let plans: [SessionPro.Plan] - let proStatus: Network.SessionPro.BackendUserProStatus? - let proAutoRenewing: Bool? - let proAccessExpiryTimestampMs: UInt64? - let proLatestPaymentItem: Network.SessionPro.PaymentItem? - let proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform - let proOriginatingAccount: SessionPro.OriginatingAccount - let proRefundingStatus: SessionPro.RefundingStatus @MainActor public func sections(viewModel: SessionProSettingsViewModel, previousState: State) -> [SectionModel] { SessionProSettingsViewModel.sections( @@ -161,15 +152,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return [ .anyConversationPinnedPriorityChanged, .profile(profile.id), - .buildVariant(sessionProManager), - .currentUserProLoadingState(sessionProManager), - .currentUserProStatus(sessionProManager), - .currentUserProAutoRenewing(sessionProManager), - .currentUserProAccessExpiryTimestampMs(sessionProManager), - .currentUserProLatestPaymentItem(sessionProManager), - .currentUserLatestPaymentOriginatingPlatform(sessionProManager), - .currentUserProOriginatingAccount(sessionProManager), - .currentUserProRefundingStatus(sessionProManager), + .currentUserProState(sessionProManager), .setting(.groupsUpgradedCounter), .setting(.proBadgesSentCounter), .setting(.longerMessagesSentCounter) @@ -179,20 +162,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType static func initialState(using dependencies: Dependencies) -> State { return State( profile: dependencies.mutate(cache: .libSession) { $0.profile }, - buildVariant: BuildVariant.current, - loadingState: .loading, + proState: dependencies[singleton: .sessionProManager].currentUserCurrentProState, numberOfGroupsUpgraded: 0, numberOfPinnedConversations: 0, numberOfProBadgesSent: 0, - numberOfLongerMessagesSent: 0, - plans: [], - proStatus: nil, - proAutoRenewing: nil, - proAccessExpiryTimestampMs: nil, - proLatestPaymentItem: nil, - proLastPaymentOriginatingPlatform: .iOS, - proOriginatingAccount: .originatingAccount, - proRefundingStatus: false + numberOfLongerMessagesSent: 0 ) } } @@ -204,20 +178,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType using dependencies: Dependencies ) async -> State { var profile: Profile = previousState.profile - var buildVariant: BuildVariant = previousState.buildVariant - var loadingState: SessionPro.LoadingState = previousState.loadingState + var proState: SessionPro.State = previousState.proState var numberOfGroupsUpgraded: Int = previousState.numberOfGroupsUpgraded var numberOfPinnedConversations: Int = previousState.numberOfPinnedConversations var numberOfProBadgesSent: Int = previousState.numberOfProBadgesSent var numberOfLongerMessagesSent: Int = previousState.numberOfLongerMessagesSent - var plans: [SessionPro.Plan] = previousState.plans - var proStatus: Network.SessionPro.BackendUserProStatus? = previousState.proStatus - var proAutoRenewing: Bool? = previousState.proAutoRenewing - var proAccessExpiryTimestampMs: UInt64? = previousState.proAccessExpiryTimestampMs - var proLatestPaymentItem: Network.SessionPro.PaymentItem? = previousState.proLatestPaymentItem - var proLastPaymentOriginatingPlatform: SessionProUI.ClientPlatform = previousState.proLastPaymentOriginatingPlatform - var proOriginatingAccount: SessionPro.OriginatingAccount = previousState.proOriginatingAccount - var proRefundingStatus: SessionPro.RefundingStatus = previousState.proRefundingStatus /// Store a local copy of the events so we can manipulate it based on the state changes let eventsToProcess: [ObservedEvent] = events @@ -225,20 +190,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType /// If we have no previous state then we need to fetch the initial state if isInitialQuery { do { - loadingState = await dependencies[singleton: .sessionProManager].loadingState - .first(defaultValue: .loading) - proStatus = await dependencies[singleton: .sessionProManager].proStatus - .first(defaultValue: nil) - proAutoRenewing = await dependencies[singleton: .sessionProManager].autoRenewing - .first(defaultValue: nil) - proAccessExpiryTimestampMs = await dependencies[singleton: .sessionProManager].accessExpiryTimestampMs - .first(defaultValue: nil) - proLatestPaymentItem = await dependencies[singleton: .sessionProManager].latestPaymentItem - .first(defaultValue: nil) - proLastPaymentOriginatingPlatform = await dependencies[singleton: .sessionProManager].latestPaymentOriginatingPlatform - .first(defaultValue: .iOS) - proRefundingStatus = await dependencies[singleton: .sessionProManager].refundingStatus - .first(defaultValue: .notRefunding) + proState = await dependencies[singleton: .sessionProManager].state + .first(defaultValue: .invalid) try await dependencies[singleton: .storage].readAsync { db in numberOfGroupsUpgraded = (db[.groupsUpgradedCounter] ?? 0) @@ -256,49 +209,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } } - /// Always try to get plans if they are empty - if plans.isEmpty { - plans = await dependencies[singleton: .sessionProManager].plans - } - /// Split the events between those that need database access and those that don't let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) /// Process any general event changes - if let value = changes.latest(.buildVariant, as: BuildVariant.self) { - buildVariant = value - } - - if let value = changes.latest(.currentUserProLoadingState, as: SessionPro.LoadingState.self) { - loadingState = value - } - - if let value = changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self) { - proStatus = value - } - - if let value = changes.latest(.currentUserProAutoRenewing, as: Bool.self) { - proAutoRenewing = value - } - - if let value = changes.latest(.currentUserProAccessExpiryTimestampMs, as: UInt64.self) { - proAccessExpiryTimestampMs = value - } - - if let value = changes.latest(.currentUserProLatestPaymentItem, as: Network.SessionPro.PaymentItem.self) { - proLatestPaymentItem = value - } - - if let value = changes.latest(.currentUserLatestPaymentOriginatingPlatform, as: SessionProUI.ClientPlatform.self) { - proLastPaymentOriginatingPlatform = value - } - - if let value = changes.latest(.currentUserProOriginatingAccount, as: SessionPro.OriginatingAccount.self) { - proOriginatingAccount = value - } - - if let value = changes.latest(.currentUserProRefundingStatus, as: SessionPro.RefundingStatus.self) { - proRefundingStatus = value + if let value = changes.latest(.currentUserProState, as: SessionPro.State.self) { + proState = value } changes.forEach(.profile, as: ProfileEvent.self) { event in @@ -348,20 +264,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return State( profile: profile, - buildVariant: buildVariant, - loadingState: loadingState, + proState: proState, numberOfGroupsUpgraded: numberOfGroupsUpgraded, numberOfPinnedConversations: numberOfPinnedConversations, numberOfProBadgesSent: numberOfProBadgesSent, - numberOfLongerMessagesSent: numberOfLongerMessagesSent, - plans: plans, - proStatus: proStatus, - proAutoRenewing: proAutoRenewing, - proAccessExpiryTimestampMs: proAccessExpiryTimestampMs, - proLatestPaymentItem: proLatestPaymentItem, - proLastPaymentOriginatingPlatform: proLastPaymentOriginatingPlatform, - proOriginatingAccount: proOriginatingAccount, - proRefundingStatus: proRefundingStatus + numberOfLongerMessagesSent: numberOfLongerMessagesSent ) } @@ -370,7 +277,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType previousState: State, viewModel: SessionProSettingsViewModel ) -> [SectionModel] { - let logo: SectionModel = SectionModel( + var logo: SectionModel = SectionModel( model: .logoWithPro, elements: [ SessionListScreenContent.ListItemInfo( @@ -378,13 +285,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType variant: .logoWithPro( info: ListItemLogoWithPro.Info( style:{ - switch state.proStatus { + switch state.proState.status { case .expired: .disabled default: .normal } }(), state: { - guard state.proStatus != .none else { + guard state.proState.status != .neverBeenPro else { return .success( description: "proFullestPotential" .put(key: "app_name", value: Constants.app_name) @@ -393,12 +300,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } - switch state.loadingState { + switch state.proState.loadingState { case .success: return .success(description: nil) case .loading: return .loading( message: { - switch state.proStatus { + switch state.proState.status { case .expired: "checkingProStatus" .put(key: "pro", value: Constants.pro) @@ -415,7 +322,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType case .error: return .error( message: { - switch state.proStatus { + switch state.proState.status { case .expired: "errorCheckingProStatus" .put(key: "pro", value: Constants.pro) @@ -433,14 +340,16 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ), onTap: { [weak viewModel] in - switch state.loadingState { + guard state.proState.status != .neverBeenPro else { return } + + switch state.proState.loadingState { case .success: break case .loading: viewModel?.showLoadingModal( from: .logoWithPro, title: { - switch state.proStatus { - case .active, .neverBeenPro, .none: + switch state.proState.status { + case .active, .neverBeenPro: "proStatusLoading" .put(key: "pro", value: Constants.pro) .localized() @@ -452,8 +361,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }(), description: { - switch state.proStatus { - case .active, .neverBeenPro, .none: + switch state.proState.status { + case .active, .neverBeenPro: "proStatusLoadingDescription" .put(key: "pro", value: Constants.pro) .localized() @@ -478,18 +387,22 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } } - ), - ( - state.proStatus != .none ? nil : - SessionListScreenContent.ListItemInfo( - id: .continueButton, - variant: .button(title: "theContinue".localized()), - onTap: { [weak viewModel] in viewModel?.updateProPlan(state: state) } - ) ) - ].compactMap { $0 } + ] ) + switch state.proState.status { + case .active, .expired: break + case .neverBeenPro: + logo.elements.append( + SessionListScreenContent.ListItemInfo( + id: .continueButton, + variant: .button(title: "theContinue".localized()), + onTap: { [weak viewModel] in viewModel?.updateProPlan(state: state) } + ) + ) + } + let proStats: SectionModel = SectionModel( model: .proStats, elements: [ @@ -507,11 +420,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: SessionListScreenContent.TextInfo( "proLongerMessagesSent" .putNumber(state.numberOfLongerMessagesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) + .put(key: "total", value: state.proState.loadingState == .loading ? "" : state.numberOfLongerMessagesSent) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: state.proState.loadingState == .loading ), ListItemDataMatrix.Info( leadingAccessory: .icon( @@ -522,11 +435,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: SessionListScreenContent.TextInfo( "proPinnedConversations" .putNumber(state.numberOfPinnedConversations) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfPinnedConversations) + .put(key: "total", value: state.proState.loadingState == .loading ? "" : state.numberOfPinnedConversations) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: state.proState.loadingState == .loading ) ], [ @@ -539,12 +452,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: SessionListScreenContent.TextInfo( "proBadgesSent" .putNumber(state.numberOfProBadgesSent) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfProBadgesSent) + .put(key: "total", value: state.proState.loadingState == .loading ? "" : state.numberOfProBadgesSent) .put(key: "pro", value: Constants.pro) .localized(), font: .Headings.H9 ), - isLoading: state.loadingState == .loading + isLoading: state.proState.loadingState == .loading ), ListItemDataMatrix.Info( leadingAccessory: .icon( @@ -555,10 +468,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: SessionListScreenContent.TextInfo( "proGroupsUpgraded" .putNumber(state.numberOfGroupsUpgraded) - .put(key: "total", value: state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) + .put(key: "total", value: state.proState.loadingState == .loading ? "" : state.numberOfGroupsUpgraded) .localized(), font: .Headings.H9, - color: state.loadingState == .loading ? .textPrimary : .disabled + color: state.proState.loadingState == .loading ? .textPrimary : .disabled ), tooltipInfo: SessionListScreenContent.TooltipInfo( id: "SessionListScreen.DataMatrix.UpgradedGroups.ToolTip", // stringlint:ignore @@ -567,13 +480,13 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType tintColor: .disabled, position: .topLeft ), - isLoading: state.loadingState == .loading + isLoading: state.proState.loadingState == .loading ) ] ] ), onTap: { [weak viewModel] in - guard state.loadingState == .loading else { return } + guard state.proState.loadingState == .loading else { return } viewModel?.showLoadingModal( from: .proStats, @@ -596,7 +509,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType let proFeatures: SectionModel = SectionModel( model: .proFeatures, - elements: ProFeaturesInfo.allCases(state.proStatus).map { info in + elements: ProFeaturesInfo.allCases(state.proState.status).map { info in SessionListScreenContent.ListItemInfo( id: info.id, variant: .cell( @@ -632,7 +545,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType iconSize: .medium, customTint: .black, gradientBackgroundColors: { - return switch state.proStatus { + return switch state.proState.status { case .expired: [ThemeValue.disabled] default: [.explicitPrimary(.orange), .explicitPrimary(.yellow)] } @@ -687,7 +600,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.proStatus { + switch state.proState.status { case .expired: return .textPrimary default: return .sessionButton_text } @@ -715,7 +628,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .squareArrowUpRight, size: .large, customTint: { - switch state.proStatus { + switch state.proState.status { case .expired: return .textPrimary default: return .sessionButton_text } @@ -728,8 +641,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ] ) - return switch (state.proStatus, state.proRefundingStatus) { - case (.none, _), (.neverBeenPro, _): [ logo, proFeatures, help ] + return switch (state.proState.status, state.proState.refundingStatus) { + case (.neverBeenPro, _): [ logo, proFeatures, help ] case (.active, .notRefunding): [ logo, proStats, proSettings, proFeatures, proManagement, help ] case (.expired, _): [ logo, proManagement, proFeatures, help ] case (.active, .refunding): [ logo, proStats, proSettings, proFeatures, help ] @@ -745,8 +658,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) -> [SessionListScreenContent.ListItemInfo] { let initialProSettingsElements: [SessionListScreenContent.ListItemInfo] - switch (state.proStatus, state.proRefundingStatus) { - case (.none, _), (.neverBeenPro, _), (.expired, _): initialProSettingsElements = [] + switch (state.proState.status, state.proState.refundingStatus) { + case (.neverBeenPro, _), (.expired, _): initialProSettingsElements = [] case (.active, .notRefunding): initialProSettingsElements = [ SessionListScreenContent.ListItemInfo( @@ -760,7 +673,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType font: .Headings.H8 ), description: { - switch state.loadingState { + switch state.proState.loadingState { case .loading: return SessionListScreenContent.TextInfo( font: .Body.smallRegular, @@ -780,7 +693,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType case .success: let expirationDate: Date = Date( - timeIntervalSince1970: floor(Double(state.proAccessExpiryTimestampMs ?? 0) / 1000) + timeIntervalSince1970: floor(Double(state.proState.accessExpiryTimestampMs ?? 0) / 1000) ) let expirationString: String = expirationDate .timeIntervalSince(viewModel.dependencies.dateNow) @@ -792,7 +705,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType return SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: ( - state.proAutoRenewing == true ? + state.proState.autoRenewing == true ? "proAutoRenewTime" .put(key: "pro", value: Constants.pro) .put(key: "time", value: expirationString) @@ -805,11 +718,11 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) } }(), - trailingAccessory: state.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) + trailingAccessory: state.proState.loadingState == .loading ? .loadingIndicator(size: .large) : .icon(.chevronRight, size: .large) ) ), onTap: { [weak viewModel] in - switch state.loadingState { + switch state.proState.loadingState { case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( @@ -851,14 +764,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType description: SessionListScreenContent.TextInfo( font: .Body.smallRegular, attributedString: "processingRefundRequest" - .put(key: "platform", value: state.proLastPaymentOriginatingPlatform.platform) + .put(key: "platform", value: state.proState.originatingPlatform.platform) .localizedFormatted(Fonts.Body.smallRegular) ), trailingAccessory: .icon(.circleAlert, size: .large) ) ), onTap: { [weak viewModel] in - switch state.loadingState { + switch state.proState.loadingState { case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( @@ -932,12 +845,12 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType state: State, viewModel: SessionProSettingsViewModel ) -> [SessionListScreenContent.ListItemInfo] { - switch (state.proStatus, state.proRefundingStatus) { - case (.none, _), (.neverBeenPro, _), (.active, .refunding): return [] + switch (state.proState.status, state.proState.refundingStatus) { + case (.neverBeenPro, _), (.active, .refunding): return [] case (.active, .notRefunding): var renewingItems: [SessionListScreenContent.ListItemInfo] = [] - if state.proAutoRenewing == true { + if state.proState.autoRenewing == true { renewingItems.append( SessionListScreenContent.ListItemInfo( id: .cancelPlan, @@ -986,10 +899,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized(), font: .Headings.H8, - color: state.loadingState == .success ? .primary : .textPrimary + color: state.proState.loadingState == .success ? .primary : .textPrimary ), description: { - switch state.loadingState { + switch state.proState.loadingState { case .success: return nil case .error: return SessionListScreenContent.TextInfo( @@ -1011,18 +924,18 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }(), trailingAccessory: ( - state.loadingState == .loading ? + state.proState.loadingState == .loading ? .loadingIndicator(size: .large) : .icon( .circlePlus, size: .large, - customTint: state.loadingState == .success ? .primary : .textPrimary + customTint: state.proState.loadingState == .success ? .primary : .textPrimary ) ) ) ), onTap: { [weak viewModel] in - switch state.loadingState { + switch state.proState.loadingState { case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( @@ -1153,7 +1066,7 @@ extension SessionProSettingsViewModel { } @MainActor func updateProPlan(state: State) { - guard state.buildVariant != .ipa else { + guard state.proState.buildVariant != .ipa else { let viewController = ModalActivityIndicatorViewController() { [weak self] modalActivityIndicator in Task { sleep(5) @@ -1170,17 +1083,8 @@ extension SessionProSettingsViewModel { rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dataModel: SessionProPaymentScreenContent.DataModel( - flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow( - plans: state.plans, - proStatus: state.proStatus, - autoRenewing: state.proAutoRenewing, - accessExpiryTimestampMs: state.proAccessExpiryTimestampMs, - latestPaymentItem: state.proLatestPaymentItem, - lastPaymentOriginatingPlatform: state.proLastPaymentOriginatingPlatform, - originatingAccount: state.proOriginatingAccount, - refundingStatus: state.proRefundingStatus - ), - plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state.proState), + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), dependencies: dependencies ) @@ -1193,14 +1097,13 @@ extension SessionProSettingsViewModel { Task.detached(priority: .userInitiated) { [weak self, manager = dependencies[singleton: .sessionProManager]] in try? await manager.refreshProState() - let status: Network.SessionPro.BackendUserProStatus = (await manager.proStatus - .first(defaultValue: nil) ?? .neverBeenPro) + let state: SessionPro.State = manager.currentUserCurrentProState await MainActor.run { [weak self] in let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: { - switch status { + switch state.status { case .active: return "proAccessRestored" .put(key: "pro", value: Constants.pro) @@ -1213,7 +1116,7 @@ extension SessionProSettingsViewModel { } }(), body: { - switch status { + switch state.status { case .active: return .text( "proAccessRestoredDescription" @@ -1233,12 +1136,12 @@ extension SessionProSettingsViewModel { ) } }(), - confirmTitle: (status == .active ? nil : "helpSupport".localized()), - cancelTitle: (status == .active ? "okay".localized() : "close".localized()), - cancelStyle: (status == .active ? .textPrimary : .danger), + confirmTitle: (state.status == .active ? nil : "helpSupport".localized()), + cancelTitle: (state.status == .active ? "okay".localized() : "close".localized()), + cancelStyle: (state.status == .active ? .textPrimary : .danger), dismissOnConfirm: false, onConfirm: { [weak self] modal in - guard status != .active else { + guard state.status != .active else { return modal.dismiss(animated: true) } @@ -1257,8 +1160,8 @@ extension SessionProSettingsViewModel { rootView: SessionProPaymentScreen( viewModel: SessionProPaymentScreenContent.ViewModel( dataModel: SessionProPaymentScreenContent.DataModel( - flow: .cancel(originatingPlatform: state.proLastPaymentOriginatingPlatform), - plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + flow: .cancel(originatingPlatform: state.proState.originatingPlatform), + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), dependencies: dependencies ) @@ -1273,11 +1176,11 @@ extension SessionProSettingsViewModel { viewModel: SessionProPaymentScreenContent.ViewModel( dataModel: SessionProPaymentScreenContent.DataModel( flow: .refund( - originatingPlatform: state.proLastPaymentOriginatingPlatform, - isNonOriginatingAccount: (state.proOriginatingAccount == .nonOriginatingAccount), + originatingPlatform: state.proState.originatingPlatform, + isNonOriginatingAccount: (state.proState.originatingAccount == .nonOriginatingAccount), requestedAt: nil ), - plans: state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), dependencies: dependencies ) @@ -1351,6 +1254,7 @@ extension SessionProSettingsViewModel { title: "proBadges".localized(), description: "proBadgesDescription".put(key: "app_name", value: Constants.app_name).localized(), accessory: .proBadgeLeading( + size: .mini, themeBackgroundColor: { return switch state { case .expired: .disabled @@ -1366,97 +1270,6 @@ extension SessionProSettingsViewModel { // MARK: - Convenience -extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { - init( - plans: [SessionPro.Plan], - proStatus: Network.SessionPro.BackendUserProStatus?, - autoRenewing: Bool?, - accessExpiryTimestampMs: UInt64?, - latestPaymentItem: Network.SessionPro.PaymentItem?, - lastPaymentOriginatingPlatform: SessionProUI.ClientPlatform, - originatingAccount: SessionPro.OriginatingAccount, - refundingStatus: SessionPro.RefundingStatus - ) { - let latestPlan: SessionPro.Plan? = plans.first { $0.variant == latestPaymentItem?.plan } - let expiryDate: Date? = accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } - - switch (proStatus, latestPlan, refundingStatus) { - case (.none, _, _), (.neverBeenPro, _, _), (.active, .none, _): self = .purchase - case (.active, .some(let plan), .notRefunding): - self = .update( - currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), - expiredOn: (expiryDate ?? Date.distantPast), - originatingPlatform: lastPaymentOriginatingPlatform, - isAutoRenewing: (autoRenewing == true) - ) - - case (.expired, _, _): self = .renew(originatingPlatform: lastPaymentOriginatingPlatform) - case (.active, .some, .refunding): - self = .refund( - originatingPlatform: lastPaymentOriginatingPlatform, - isNonOriginatingAccount: (originatingAccount == .nonOriginatingAccount), - requestedAt: (latestPaymentItem?.refundRequestedTimestampMs).map { - Date(timeIntervalSince1970: (Double($0) / 1000)) - } - ) - } - } -} - -extension SessionProPaymentScreenContent.SessionProPlanInfo { - init(plan: SessionPro.Plan) { - let price: Double = Double(truncating: plan.price as NSNumber) - let pricePerMonth: Double = Double(truncating: plan.pricePerMonth as NSNumber) - let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true)) - let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true)) - - self = SessionProPaymentScreenContent.SessionProPlanInfo( - duration: plan.durationMonths, - totalPrice: price, - pricePerMonth: pricePerMonth, - discountPercent: plan.discountPercent, - titleWithPrice: { - switch plan.variant { - case .none, .oneMonth: - return "proPriceOneMonth" - .put(key: "monthly_price", value: formattedPricePerMonth) - .localized() - - case .threeMonths: - return "proPriceThreeMonths" - .put(key: "monthly_price", value: formattedPricePerMonth) - .localized() - - case .twelveMonths: - return "proPriceTwelveMonths" - .put(key: "monthly_price", value: formattedPricePerMonth) - .localized() - } - }(), - subtitleWithPrice: { - switch plan.variant { - case .none, .oneMonth: - return "proBilledMonthly" - .put(key: "price", value: formattedPrice) - .localized() - - case .threeMonths: - return "proBilledQuarterly" - .put(key: "price", value: formattedPrice) - .localized() - - case .twelveMonths: - return "proBilledAnnually" - .put(key: "price", value: formattedPrice) - .localized() - } - }() - ) - } -} - -// MARK: - Convenience - private extension ObservedEvent { var dataRequirement: EventDataRequirement { switch (key, key.generic) { diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 5db69cb599..a2991b7122 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -38,7 +38,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self.dependencies = dependencies self.internalState = State.initialState( userSessionId: dependencies[cache: .general].sessionId, - proStatus: dependencies[singleton: .sessionProManager].currentUserCurrentProStatus + proState: dependencies[singleton: .sessionProManager].currentUserCurrentProState ) bindState() @@ -157,7 +157,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile - let proStatus: Network.SessionPro.BackendUserProStatus? + let proState: SessionPro.State let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -175,23 +175,22 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return [ .profile(userSessionId.hexString), + .currentUserProState(sessionProManager), .feature(.serviceNetwork), .feature(.forceOffline), .setting(.developerModeEnabled), - .setting(.hideRecoveryPasswordPermanently), - .currentUserProLoadingState(sessionProManager), - .currentUserProStatus(sessionProManager), + .setting(.hideRecoveryPasswordPermanently) ] } static func initialState( userSessionId: SessionId, - proStatus: Network.SessionPro.BackendUserProStatus? + proState: SessionPro.State ) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), - proStatus: proStatus, + proState: proState, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -225,7 +224,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile - var proStatus: Network.SessionPro.BackendUserProStatus? = previousState.proStatus + var proState: SessionPro.State = previousState.proState var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -234,7 +233,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl if isInitialFetch { serviceNetwork = dependencies[feature: .serviceNetwork] forceOffline = dependencies[feature: .forceOffline] - proStatus = await dependencies[singleton: .sessionProManager].proStatus.first(defaultValue: nil) + proState = await dependencies[singleton: .sessionProManager].state.first(defaultValue: .invalid) dependencies.mutate(cache: .libSession) { libSession in profile = libSession.profile @@ -292,15 +291,15 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - if let value = changes.latest(.currentUserProStatus, as: Network.SessionPro.BackendUserProStatus.self) { - proStatus = value + if let value = changes.latest(.currentUserProState, as: SessionPro.State.self) { + proState = value } /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, - proStatus: proStatus, + proState: proState, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -342,7 +341,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onTap: { [weak viewModel] in viewModel?.updateProfilePicture( currentUrl: state.profile.displayPictureUrl, - proStatus: state.proStatus + proState: state.proState ) } ), @@ -353,8 +352,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, trailingImage: { - switch state.proStatus { - case .none, .neverBeenPro: return nil + switch state.proState.status { + case .neverBeenPro: return nil case .active: return SessionProBadge.trailingImage( size: .medium, @@ -448,7 +447,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let donationAndNetwork: SectionModel // FIXME: [PRO] Should be able to remove this once pro is properly enabled - if viewModel.dependencies[feature: .sessionProEnabled] { + if state.proState.sessionProEnabled { sessionProAndCommunity = SectionModel( model: .sessionProAndCommunity, elements: [ @@ -456,8 +455,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl id: .sessionPro, leadingAccessory: .proBadge(size: .small), title: { - switch state.proStatus { - case .none, .neverBeenPro: + switch state.proState.status { + case .neverBeenPro: return "upgradeSession" .put(key: "app_name", value: Constants.app_name) .localized() @@ -830,7 +829,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture( currentUrl: String?, - proStatus: Network.SessionPro.BackendUserProStatus? + proState: SessionPro.State ) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false @@ -857,29 +856,19 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { - switch (dependencies[feature: .sessionProEnabled], proStatus) { + switch (proState.sessionProEnabled, proState.status) { case (false, _): return nil case (true, .active): - return "proAnimatedDisplayPictureModalDescription" - .localized() - .addProBadge( - at: .leading, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small, - using: dependencies - ) + return SessionListScreenContent.TextInfo( + "proAnimatedDisplayPictureModalDescription".localized(), + accessory: .proBadgeLeading(size: .small, themeBackgroundColor: .textSecondary) + ) case (true, _): - return "proAnimatedDisplayPicturesNonProModalDescription" - .localized() - .addProBadge( - at: .trailing, - font: .systemFont(ofSize: Values.smallFontSize), - textColor: .textSecondary, - proBadgeSize: .small, - using: dependencies - ) + return SessionListScreenContent.TextInfo( + "proAnimatedDisplayPicturesNonProModalDescription".localized(), + accessory: .proBadgeTrailing(size: .small, themeBackgroundColor: .textSecondary) + ) } }(), accessibility: Accessibility( @@ -890,7 +879,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onProBageTapped: { [weak self, dependencies] in Task { @MainActor in dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (proStatus == .active)), + .animatedProfileImage(isSessionProActivated: (proState.status == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } @@ -938,9 +927,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) var didShowCTAModal: Bool = false - if isAnimatedImage && !dependencies[feature: .sessionProEnabled] { + if isAnimatedImage && proState.sessionProEnabled { didShowCTAModal = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .animatedProfileImage(isSessionProActivated: (proStatus == .active)), + .animatedProfileImage(isSessionProActivated: (proState.status == .active)), presenting: { modal in self?.transitionToScreen(modal, transitionType: .present) } diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 4ff22dcb9f..c363758b12 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -4,21 +4,7 @@ import UIKit import SessionUIKit import SessionUtilitiesKit -public extension SessionProBadge.Size{ - // stringlint:ignore_contents - var cacheKey: String { - switch self { - case .mini: return "SessionProBadge.Mini" - case .small: return "SessionProBadge.Small" - case .medium: return "SessionProBadge.Medium" - case .large: return "SessionProBadge.Large" - } - } -} - public extension SessionProBadge { - fileprivate static let accessibilityLabel: String = Constants.app_pro - static func trailingImage( size: SessionProBadge.Size, themeBackgroundColor: ThemeValue @@ -26,62 +12,54 @@ public extension SessionProBadge { return ( .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), accessibilityLabel: SessionProBadge.accessibilityLabel, - { SessionProBadge(size: size) } + { SessionProBadge(size: size, themeBackgroundColor: themeBackgroundColor) } ) } } -public extension String { - enum SessionProBadgePosition { - case leading, trailing - } - - func addProBadge( - at postion: SessionProBadgePosition, - font: UIFont, - textColor: ThemeValue = .textPrimary, - proBadgeSize: SessionProBadge.Size, - spacing: String = " ", - using dependencies: Dependencies - ) -> ThemedAttributedString { - let base = ThemedAttributedString() - switch postion { - case .leading: - base.append( - ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image( - for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), - generator: { SessionProBadge(size: proBadgeSize) } - ), - SessionProBadge.accessibilityLabel - ) - }, - referenceFont: font - ) - ) - base.append(ThemedAttributedString(string: spacing)) - base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - case .trailing: - base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - base.append(ThemedAttributedString(string: spacing)) - base.append( - ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image( - for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), - generator: { SessionProBadge(size: proBadgeSize) } - ), - SessionProBadge.accessibilityLabel - ) - }, - referenceFont: font - ) - ) - } - - return base - } -} +//public extension String { +// enum SessionProBadgePosition { +// case leading, trailing +// } +// +// @MainActor func addProBadge( +// at postion: SessionProBadgePosition, +// font: UIFont, +// textColor: ThemeValue = .textPrimary, +// proBadgeSize: SessionProBadge.Size, +// spacing: String = " ", +// using dependencies: Dependencies +// ) -> ThemedAttributedString { +// let proBadgeImage: UIImage = UIView.image( +// for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), +// generator: { SessionProBadge(size: proBadgeSize) } +// ) +// +// let base: ThemedAttributedString = ThemedAttributedString() +// +// switch postion { +// case .leading: +// base.append( +// ThemedAttributedString( +// image: proBadgeImage, +// accessibilityLabel: SessionProBadge.accessibilityLabel, +// font: font +// ) +// ) +// base.append(ThemedAttributedString(string: spacing)) +// base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) +// case .trailing: +// base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) +// base.append(ThemedAttributedString(string: spacing)) +// base.append( +// ThemedAttributedString( +// image: proBadgeImage, +// accessibilityLabel: SessionProBadge.accessibilityLabel, +// font: font +// ) +// ) +// } +// +// return base +// } +//} diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 64707d7e16..507efd4ac2 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -29,51 +29,30 @@ public enum SessionPro { public actor SessionProManager: SessionProManagerType { private let dependencies: Dependencies nonisolated private let syncState: SessionProManagerSyncState - private var isRefreshingState: Bool = false + private var transactionObservingTask: Task? private var proMockingObservationTask: Task? + + private var isRefreshingState: Bool = false private var rotatingKeyPair: KeyPair? - public var plans: [SessionPro.Plan] = [] - - nonisolated private let buildVariantStream: CurrentValueAsyncStream = CurrentValueAsyncStream(BuildVariant.current) - nonisolated private let loadingStateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.loading) - nonisolated private let proStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let autoRenewingStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let accessExpiryTimestampMsStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let latestPaymentItemStream: CurrentValueAsyncStream = CurrentValueAsyncStream(nil) - nonisolated private let latestPaymentOriginatingPlatformStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.iOS) - nonisolated private let originatingAccountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.originatingAccount) - nonisolated private let refundingStatusStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.notRefunding) + + nonisolated private let stateStream: CurrentValueAsyncStream = CurrentValueAsyncStream(.invalid) nonisolated public var currentUserCurrentRotatingKeyPair: KeyPair? { syncState.rotatingKeyPair } - nonisolated public var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { - syncState.proStatus - } - nonisolated public var currentUserCurrentProProof: Network.SessionPro.ProProof? { syncState.proProof } - nonisolated public var currentUserCurrentProProfileFeatures: SessionPro.ProfileFeatures? { syncState.proProfileFeatures } - nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.proStatus == .active } + nonisolated public var currentUserCurrentProState: SessionPro.State { syncState.state } + nonisolated public var currentUserIsCurrentlyPro: Bool { syncState.state.status == .active } nonisolated public var pinnedConversationLimit: Int { SessionPro.PinnedConversationLimit } nonisolated public var characterLimit: Int { (currentUserIsCurrentlyPro ? SessionPro.ProCharacterLimit : SessionPro.CharacterLimit) } + + nonisolated public var state: AsyncStream { stateStream.stream } nonisolated public var currentUserIsPro: AsyncStream { - proStatusStream.stream - .map { $0 == .active } + stateStream.stream + .map { $0.status == .active } .asAsyncStream() } - nonisolated public var buildVariant: AsyncStream { buildVariantStream.stream } - nonisolated public var loadingState: AsyncStream { loadingStateStream.stream } - nonisolated public var proStatus: AsyncStream { proStatusStream.stream } - nonisolated public var autoRenewing: AsyncStream { autoRenewingStream.stream } - nonisolated public var accessExpiryTimestampMs: AsyncStream { accessExpiryTimestampMsStream.stream } - nonisolated public var latestPaymentItem: AsyncStream { latestPaymentItemStream.stream } - nonisolated public var latestPaymentOriginatingPlatform: AsyncStream { - latestPaymentOriginatingPlatformStream.stream - } - nonisolated public var originatingAccount: AsyncStream { originatingAccountStream.stream } - nonisolated public var refundingStatus: AsyncStream { refundingStatusStream.stream } - // MARK: - Initialization public init(using dependencies: Dependencies) { @@ -82,7 +61,8 @@ public actor SessionProManager: SessionProManagerType { Task { await updateWithLatestFromUserConfig() - await startProStatusObservations() + await startTransactionObservation() + await startProMockingObservations() /// Kick off a refresh so we know we have the latest state (if it's the main app) if dependencies[singleton: .appContext].isMainApp { @@ -92,6 +72,7 @@ public actor SessionProManager: SessionProManagerType { } deinit { + transactionObservingTask?.cancel() proMockingObservationTask?.cancel() } @@ -159,7 +140,7 @@ public actor SessionProManager: SessionProManagerType { let featuresForMessage: SessionPro.FeaturesForMessage = features( for: ((message as? VisibleMessage)?.text ?? "") ) - let profileFeatures: SessionPro.ProfileFeatures = (syncState.proProfileFeatures ?? .none) + let profileFeatures: SessionPro.ProfileFeatures = syncState.state.profileFeatures /// We only want to attach the `proFeatures` and `proProof` if a pro feature is _actually_ used guard @@ -167,7 +148,7 @@ public actor SessionProManager: SessionProManagerType { profileFeatures != .none || featuresForMessage.features != .none ), - let proof: Network.SessionPro.ProProof = syncState.proProof + let proof: Network.SessionPro.ProProof = syncState.state.proof else { if featuresForMessage.status != .success { Log.error(.sessionPro, "Failed to get features for outgoing message due to error: \(featuresForMessage.error ?? "Unknown error")") @@ -183,6 +164,76 @@ public actor SessionProManager: SessionProManagerType { return updatedMessage } + @discardableResult @MainActor public func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + onConfirm: (() -> Void)?, + onCancel: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + guard syncState.dependencies[feature: .sessionProEnabled] else { return false } + + switch variant { + case .groupLimit: break /// The `groupLimit` CTA can be shown for Session Pro users as well + default: + guard syncState.state.status != .active else { return false } + + break + } + + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + variant: variant, + dataManager: syncState.dependencies[singleton: .imageDataManager], + sessionProUIManager: self, + dismissType: dismissType, + onConfirm: onConfirm, + onCancel: onCancel, + afterClosed: afterClosed + ) + ) + presenting?(sessionProModal) + + return true + } + + public func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? { + let state: SessionPro.State = await stateStream.getCurrent() + let dateNow: Date = dependencies.dateNow + let expiryInSeconds: TimeInterval = (state.accessExpiryTimestampMs + .map { Date(timeIntervalSince1970: (Double($0) / 1000)).timeIntervalSince(dateNow) } ?? 0) + let variant: ProCTAModal.Variant + + switch (state.status, state.autoRenewing, state.refundingStatus) { + case (.neverBeenPro, _, _), (.active, _, .refunding), (.active, true, .notRefunding): return nil + case (.active, false, .notRefunding): + guard expiryInSeconds <= 7 * 24 * 60 * 60 else { return nil } + + variant = .expiring( + timeLeft: expiryInSeconds.formatted( + format: .long, + allowedUnits: [ .day, .hour, .minute ] + ) + ) + + case (.expired, _, _): + guard expiryInSeconds <= 30 * 24 * 60 * 60 else { return nil } + + variant = .expiring(timeLeft: nil) + } + + // TODO: [PRO] Do we need to remove this flag if it's re-purchased or extended? + guard !dependencies[defaults: .standard, key: .hasShownProExpiringCTA] else { return nil } + + let paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow = SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state) + let planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo] = state.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } + + return (variant, paymentFlow, planInfo) + } + + // MARK: - State Management + public func updateWithLatestFromUserConfig() async { if #available(iOS 16.0, *) { do { try await dependencies.waitUntilInitialised(cache: .libSession) } @@ -193,7 +244,7 @@ public actor SessionProManager: SessionProManagerType { while true { try? await Task.sleep(for: .milliseconds(500)) - /// If `libSession` has data we can break + /// If `libSession` has data we can stop waiting if !dependencies[cache: .libSession].isEmpty { break } @@ -201,16 +252,16 @@ public actor SessionProManager: SessionProManagerType { } /// Get the cached pro state from libSession - typealias ProState = ( + typealias ProInfo = ( proConfig: SessionPro.ProConfig?, profile: Profile, accessExpiryTimestampMs: UInt64 ) - let proState: ProState = dependencies.mutate(cache: .libSession) { + let proInfo: ProInfo = dependencies.mutate(cache: .libSession) { ($0.proConfig, $0.profile, $0.proAccessExpiryTimestampMs) } - let rotatingKeyPair: KeyPair? = try? proState.proConfig.map { config in + let rotatingKeyPair: KeyPair? = try? proInfo.proConfig.map { config in guard config.rotatingPrivateKey.count >= 32 else { return nil } return try dependencies[singleton: .crypto].tryGenerate( @@ -218,10 +269,9 @@ public actor SessionProManager: SessionProManagerType { ) } - /// Update the `syncState` first (just in case an update triggered from the async state results in something accessing the - /// sync state) + /// Infer the `proStatus` based on the config state (since we don't sync the status) let proStatus: Network.SessionPro.BackendUserProStatus = { - guard let proof: Network.SessionPro.ProProof = proState.proConfig?.proProof else { + guard let proof: Network.SessionPro.ProProof = proInfo.proConfig?.proProof else { return .neverBeenPro } @@ -231,67 +281,157 @@ public actor SessionProManager: SessionProManagerType { ) return (proofIsActive ? .active : .expired) }() - syncState.update( - rotatingKeyPair: .set(to: rotatingKeyPair), - proStatus: .set(to: mockedIfNeeded(proStatus)), - proProof: .set(to: proState.proConfig?.proProof), - proProfileFeatures: .set(to: proState.profile.proFeatures) + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), + proof: .set(to: proInfo.proConfig?.proProof), + profileFeatures: .set(to: proInfo.profile.proFeatures), + accessExpiryTimestampMs: .set(to: proInfo.accessExpiryTimestampMs), + using: dependencies ) - /// Then update the async state and streams - let oldAccessExpiryTimestampMs: UInt64? = await self.accessExpiryTimestampMsStream.getCurrent() + /// Store the updated events and emit updates + self.syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + state: .set(to: updatedState) + ) self.rotatingKeyPair = rotatingKeyPair - await self.proStatusStream.send(mockedIfNeeded(proStatus)) - await self.accessExpiryTimestampMsStream.send(proState.accessExpiryTimestampMs) - await self.sendUpdatedRefundingStatusState() + await self.stateStream.send(updatedState) /// If the `accessExpiryTimestampMs` value changed then we should trigger a refresh because it generally means that /// other device did something that should refresh the pro state - if proState.accessExpiryTimestampMs != oldAccessExpiryTimestampMs { + if updatedState.accessExpiryTimestampMs != oldState.accessExpiryTimestampMs { try? await refreshProState() } } - @discardableResult @MainActor public func showSessionProCTAIfNeeded( - _ variant: ProCTAModal.Variant, - dismissType: Modal.DismissType, - onConfirm: (() -> Void)?, - onCancel: (() -> Void)?, - afterClosed: (() -> Void)?, - presenting: ((UIViewController) -> Void)? - ) -> Bool { - guard syncState.dependencies[feature: .sessionProEnabled] else { return false } + public func purchasePro(productId: String) async throws { + // TODO: [PRO] Show a modal indicating that we are doing a "DEV" purchase when on the simulator + guard !dependencies[feature: .fakeAppleSubscriptionForDev] else { + let bytes: [UInt8] = try dependencies[singleton: .crypto].tryGenerate(.randomBytes(8)) + return try await addProPayment(transactionId: "DEV.\(bytes.toHexString())") // stringlint:ignore + } - switch variant { - case .groupLimit: break /// The `groupLimit` CTA can be shown for Session Pro users as well - default: - guard syncState.proStatus != .active else { return false } - - break + let state: SessionPro.State = await stateStream.getCurrent() + + guard let product: Product = state.products.first(where: { $0.id == productId }) else { + Log.error(.sessionPro, "Attempted to purchase invalid product: \(productId)") + // TODO: [PRO] Better errors + throw NetworkError.explicit("Unable to find product to purchase") } - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: variant, - dataManager: syncState.dependencies[singleton: .imageDataManager], - sessionProUIManager: self, - dismissType: dismissType, - onConfirm: onConfirm, - onCancel: onCancel, - afterClosed: afterClosed - ) - ) - presenting?(sessionProModal) + // TODO: [PRO] This results in an error being logged: "Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch." + let result: Product.PurchaseResult = try await product.purchase() + + guard case .success(let verificationResult) = result else { + switch result { + case .success: + // TODO: [PRO] Better errors + throw NetworkError.explicit("Invalid Case") + case .pending: + // TODO: [PRO] How do we handle this? Let the user continue what they were doing and listen for transaction updates? What if they restart the app??? + throw NetworkError.explicit("TODO: Pending transaction") + + case .userCancelled: throw NetworkError.explicit("User Cancelled") + + @unknown default: + // TODO: [PRO] Better errors + throw NetworkError.explicit("An unhandled purchase result was received: \(result)") + } + } - return true + let transaction: Transaction = try verificationResult.payloadValue + + /// There is a race condition where the client can try to register their payment before the Pro Backend has received the notification + /// from Apple that the payment has happened, due to this we need to try add the payment a few times with a small delay before + /// considering it an actual failure + let maxRetries: Int = 3 + + for index in 1...maxRetries { + do { + try await addProPayment(transactionId: "\(transaction.id)") + break /// Successfully registered the payment with the backend so no need to retry + } + catch { + /// If we reached the last retry then throw the error + if index == maxRetries { + Log.error(.sessionPro, "Failed to notify Pro backend of purchase due to error(s): \(error)") + throw error + } + + /// Small incremental backoff before trying again + try await Task.sleep(for: .milliseconds(index * 300)) + } + } + await transaction.finish() } - public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { - // TODO: [PRO] Need to actually implement this - dependencies.set(feature: .mockCurrentUserSessionProBackendStatus, to: .simulate(.active)) - await proStatusStream.send(.active) - await sendUpdatedRefundingStatusState() - completion?(true) + public func addProPayment(transactionId: String) async throws { + // TODO: [PRO] Need to sort out logic for rotating this key pair. + /// First we need to add the pro payment to the Pro backend + let rotatingKeyPair: KeyPair = try ( + self.rotatingKeyPair ?? + dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) + ) + let request = try Network.SessionPro.addProPayment( + transactionId: transactionId, + masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + rotatingKeyPair: rotatingKeyPair, + requestTimeout: 5, /// 5s timeout as per PRD + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + // TODO: [PRO] Need to show the error modal + let errorString: String = response.header.errors.joined(separator: ", ") + throw NetworkError.explicit(errorString) + } + + /// Update the config + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProConfig( + proConfig: SessionPro.ProConfig( + rotatingPrivateKey: rotatingKeyPair.secretKey, + proProof: response.proof + ) + ) + } + } + } + + /// Send the proof and status events on the streams + /// + /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and + /// received a pro proof + let proofIsActive: Bool = proProofIsActive( + for: response.proof, + atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), + proof: .set(to: response.proof), + using: dependencies + ) + + syncState.update( + rotatingKeyPair: .set(to: rotatingKeyPair), + state: .set(to: updatedState) + ) + self.rotatingKeyPair = rotatingKeyPair + await self.stateStream.send(updatedState) + + /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other + /// edge-cases since it's the main driver to the Pro state) + try? await refreshProState() } // MARK: - Pro State Management @@ -304,13 +444,33 @@ public actor SessionProManager: SessionProManagerType { defer { isRefreshingState = false } /// Only reset the `loadingState` if it's currently in an error state - if await loadingStateStream.getCurrent() == .error { - await loadingStateStream.send(mockedIfNeeded(.loading)) + var oldState: SessionPro.State = await stateStream.getCurrent() + var updatedState: SessionPro.State = oldState + + if oldState.loadingState == .error { + updatedState = oldState.with( + loadingState: .set(to: .loading), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState } /// Get the product list from the AppStore first (need this to populate the UI) - if plans.isEmpty { - plans = try await SessionPro.Plan.retrievePlans() + if oldState.products.isEmpty || oldState.plans.isEmpty { + let result: (products: [Product], plans: [SessionPro.Plan]) = try await SessionPro.Plan + .retrieveProductsAndPlans() + updatedState = oldState.with( + products: .set(to: result.products), + plans: .set(to: result.plans), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState } // FIXME: Await network connectivity when the refactored networking is merged @@ -327,36 +487,53 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") Log.error(.sessionPro, "Failed to retrieve pro details due to error(s): \(errorString)") - await loadingStateStream.send(mockedIfNeeded(.error)) + + updatedState = oldState.with( + loadingState: .set(to: .error), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) throw NetworkError.explicit(errorString) } + updatedState = oldState.with( + status: .set(to: response.status), + autoRenewing: .set(to: response.autoRenewing), + accessExpiryTimestampMs: .set(to: response.expiryTimestampMs), + latestPaymentItem: .set(to: response.items.first), + using: dependencies + ) - syncState.update(proStatus: .set(to: mockedIfNeeded(response.status))) - await self.proStatusStream.send(mockedIfNeeded(response.status)) - await self.autoRenewingStream.send(response.autoRenewing) - await self.accessExpiryTimestampMsStream.send(response.expiryTimestampMs) - await self.latestPaymentItemStream.send(response.items.first) - await self.latestPaymentOriginatingPlatformStream.send(mockedIfNeeded( - SessionProUI.ClientPlatform(response.items.first?.paymentProvider) - )) - await self.sendUpdatedRefundingStatusState() + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState switch response.status { case .active: try await refreshProProofIfNeeded( - accessExpiryTimestampMs: response.expiryTimestampMs, - autoRenewing: response.autoRenewing, - status: response.status + currentProof: updatedState.proof, + accessExpiryTimestampMs: (updatedState.accessExpiryTimestampMs ?? 0), + autoRenewing: updatedState.autoRenewing, + status: updatedState.status ) - case .neverBeenPro: try await clearProProof() - case .expired: try await clearProProof() + case .neverBeenPro: try await clearProProofFromConfig() + case .expired: try await clearProProofFromConfig() } - await loadingStateStream.send(mockedIfNeeded(.success)) + updatedState = oldState.with( + loadingState: .set(to: .success), + using: dependencies + ) + + syncState.update(state: .set(to: updatedState)) + await self.stateStream.send(updatedState) + oldState = updatedState } public func refreshProProofIfNeeded( + currentProof: Network.SessionPro.ProProof?, accessExpiryTimestampMs: UInt64, autoRenewing: Bool, status: Network.SessionPro.BackendUserProStatus @@ -364,9 +541,7 @@ public actor SessionProManager: SessionProManagerType { guard status == .active else { return } let needsNewProof: Bool = { - guard let currentProof: Network.SessionPro.ProProof = syncState.proProof else { - return true - } + guard let currentProof else { return true } let sixtyMinutesBeforeAccessExpiry: UInt64 = (accessExpiryTimestampMs - (60 * 60)) let sixtyMinutesBeforeProofExpiry: UInt64 = (currentProof.expiryUnixTimestampMs - (60 * 60)) @@ -426,81 +601,22 @@ public actor SessionProManager: SessionProManagerType { atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) - syncState.update( - rotatingKeyPair: .set(to: rotatingKeyPair), - proStatus: .set(to: mockedIfNeeded(proStatus)), - proProof: .set(to: response.proof) - ) - self.rotatingKeyPair = rotatingKeyPair - await self.proStatusStream.send(mockedIfNeeded(proStatus)) - await self.sendUpdatedRefundingStatusState() - } - - public func addProPayment(transactionId: String) async throws { - /// First we need to add the pro payment to the Pro backend - let rotatingKeyPair: KeyPair = try ( - self.rotatingKeyPair ?? - dependencies[singleton: .crypto].tryGenerate(.ed25519KeyPair()) - ) - let request = try Network.SessionPro.addProPayment( - transactionId: transactionId, - masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), - rotatingKeyPair: rotatingKeyPair, + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + status: .set(to: proStatus), using: dependencies ) - // FIXME: Make this async/await when the refactored networking is merged - let response: Network.SessionPro.AddProPaymentOrGenerateProProofResponse = try await request - .send(using: dependencies) - .values - .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() - - guard response.header.errors.isEmpty else { - let errorString: String = response.header.errors.joined(separator: ", ") - Log.error(.sessionPro, "Transaction submission failed due to error(s): \(errorString)") - throw NetworkError.explicit(errorString) - } - /// Update the config - try await dependencies[singleton: .storage].writeAsync { [dependencies] db in - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProConfig( - proConfig: SessionPro.ProConfig( - rotatingPrivateKey: rotatingKeyPair.secretKey, - proProof: response.proof - ) - ) - } - } - } - - /// Send the proof and status events on the streams - /// - /// **Note:** We can assume that the users status is `active` since they just successfully added a pro payment and - /// received a pro proof - let proofIsActive: Bool = proProofIsActive( - for: response.proof, - atTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - let proStatus: Network.SessionPro.BackendUserProStatus = (proofIsActive ? .active : .expired) syncState.update( rotatingKeyPair: .set(to: rotatingKeyPair), - proStatus: .set(to: mockedIfNeeded(proStatus)), - proProof: .set(to: response.proof) + state: .set(to: updatedState) ) self.rotatingKeyPair = rotatingKeyPair - await self.proStatusStream.send(mockedIfNeeded(proStatus)) - await self.sendUpdatedRefundingStatusState() - - /// Just in case we refresh the pro state (this will avoid needless requests based on the current state but will resolve other - /// edge-cases since it's the main driver to the Pro state) - try? await refreshProState() + await self.stateStream.send(updatedState) } - public func requestRefund( - scene: UIWindowScene - ) async throws { - guard let latestPaymentItem: Network.SessionPro.PaymentItem = await latestPaymentItemStream.getCurrent() else { + public func requestRefund(scene: UIWindowScene) async throws { + guard let latestPaymentItem: Network.SessionPro.PaymentItem = await stateStream.getCurrent().latestPaymentItem else { throw NetworkError.explicit("No latest payment item") } @@ -569,25 +685,35 @@ public actor SessionProManager: SessionProManagerType { /// Need to refresh the pro state to get the updated payment item (which should now include a `refundRequestedTimestampMs`) try await refreshProState() } + + public func cancelPro(scene: UIWindowScene) async throws { + // TODO: [PRO] Need to add this + } // MARK: - Internal Functions - /// The user is in a refunding state when their pro status is `active` and the `refundRequestedTimestampMs` is not `0` - private func sendUpdatedRefundingStatusState() async { - let status: Network.SessionPro.BackendUserProStatus? = await proStatusStream.getCurrent() - let paymentItem: Network.SessionPro.PaymentItem? = await latestPaymentItemStream.getCurrent() - - await refundingStatusStream.send( - mockedIfNeeded( - SessionPro.RefundingStatus( - status == .active && - (paymentItem?.refundRequestedTimestampMs ?? 0) > 0 - ) - ) - ) + private func startTransactionObservation() { + transactionObservingTask = Task { + for await result in Transaction.updates { + do { + switch result { + case .verified(let transaction): + // TODO: [PRO] Need to actually handle this case (send to backend) + break + + case .unverified(_, let error): + Log.error(.sessionPro, "Received an unverified transaction update: \(error)") + } + + } + catch { + Log.error(.sessionPro, "Failed to retrieve transaction from update: \(error)") + } + } + } } - private func clearProProof() async throws { + private func clearProProofFromConfig() async throws { try await dependencies[singleton: .storage].writeAsync { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile) { _ in @@ -596,18 +722,6 @@ public actor SessionProManager: SessionProManagerType { } } } - - private func updateExpiryCTAs( - accessExpiryTimestampMs: UInt64, - autoRenewing: Bool, - status: Network.SessionPro.BackendUserProStatus - ) async { - let now: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let sevenDaysBeforeExpiry: UInt64 = (accessExpiryTimestampMs - (7 * 60 * 60)) - let thirtyDaysAfterExpiry: UInt64 = (accessExpiryTimestampMs + (30 * 60 * 60)) - - // TODO: [PRO] Need to add these in (likely part of pro settings) - } } // MARK: - SyncState @@ -616,15 +730,11 @@ private final class SessionProManagerSyncState { private let lock: NSLock = NSLock() private let _dependencies: Dependencies private var _rotatingKeyPair: KeyPair? = nil - private var _proStatus: Network.SessionPro.BackendUserProStatus? = nil - private var _proProof: Network.SessionPro.ProProof? = nil - private var _proProfileFeatures: SessionPro.ProfileFeatures = .none + private var _state: SessionPro.State = .invalid fileprivate var dependencies: Dependencies { lock.withLock { _dependencies } } fileprivate var rotatingKeyPair: KeyPair? { lock.withLock { _rotatingKeyPair } } - fileprivate var proStatus: Network.SessionPro.BackendUserProStatus? { lock.withLock { _proStatus } } - fileprivate var proProof: Network.SessionPro.ProProof? { lock.withLock { _proProof } } - fileprivate var proProfileFeatures: SessionPro.ProfileFeatures? { lock.withLock { _proProfileFeatures } } + fileprivate var state: SessionPro.State { lock.withLock { _state } } fileprivate init(using dependencies: Dependencies) { self._dependencies = dependencies @@ -632,15 +742,11 @@ private final class SessionProManagerSyncState { fileprivate func update( rotatingKeyPair: Update = .useExisting, - proStatus: Update = .useExisting, - proProof: Update = .useExisting, - proProfileFeatures: Update = .useExisting + state: Update = .useExisting ) { lock.withLock { self._rotatingKeyPair = rotatingKeyPair.or(self._rotatingKeyPair) - self._proStatus = proStatus.or(self._proStatus) - self._proProof = proProof.or(self._proProof) - self._proProfileFeatures = proProfileFeatures.or(self._proProfileFeatures) + self._state = state.or(self._state) } } } @@ -648,23 +754,11 @@ private final class SessionProManagerSyncState { // MARK: - SessionProManagerType public protocol SessionProManagerType: SessionProUIManagerType { - var plans: [SessionPro.Plan] { get } - nonisolated var characterLimit: Int { get } nonisolated var currentUserCurrentRotatingKeyPair: KeyPair? { get } - nonisolated var currentUserCurrentProStatus: Network.SessionPro.BackendUserProStatus? { get } - nonisolated var currentUserCurrentProProof: Network.SessionPro.ProProof? { get } - nonisolated var currentUserCurrentProProfileFeatures: SessionPro.ProfileFeatures? { get } - // TODO: [PRO] Need to finish off the "buildVariant" logic - nonisolated var buildVariant: AsyncStream { get } - nonisolated var loadingState: AsyncStream { get } - nonisolated var proStatus: AsyncStream { get } - nonisolated var autoRenewing: AsyncStream { get } - nonisolated var accessExpiryTimestampMs: AsyncStream { get } - nonisolated var latestPaymentItem: AsyncStream { get } - nonisolated var latestPaymentOriginatingPlatform: AsyncStream { get } - nonisolated var originatingAccount: AsyncStream { get } - nonisolated var refundingStatus: AsyncStream { get } + nonisolated var currentUserCurrentProState: SessionPro.State { get } + + nonisolated var state: AsyncStream { get } nonisolated func proStatus( for proof: Network.SessionPro.ProProof?, @@ -677,119 +771,46 @@ public protocol SessionProManagerType: SessionProUIManagerType { ) -> Bool nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage nonisolated func attachProInfoIfNeeded(message: Message) -> Message + func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? + + // MARK: - State Management + func updateWithLatestFromUserConfig() async - func refreshProState() async throws + func purchasePro(productId: String) async throws func addProPayment(transactionId: String) async throws + func refreshProState() async throws func requestRefund(scene: UIWindowScene) async throws -} - -// MARK: - Convenience - -extension SessionProUI.ClientPlatform { - /// The originating platform the latest payment came from - /// - /// **Note:** There may not be a latest payment, in which case we default to `iOS` because we are on an `iOS` device - init(_ provider: Network.SessionPro.PaymentProvider?) { - switch provider { - case .none: self = .iOS - case .appStore: self = .iOS - case .playStore: self = .android - } - } + func cancelPro(scene: UIWindowScene) async throws } // MARK: - Observations // stringlint:ignore_contents public extension ObservableKey { - static func buildVariant(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "buildVariant", - generic: .buildVariant - ) { [weak manager] in manager?.buildVariant } - } - - static func currentUserProLoadingState(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProLoadingState", - generic: .currentUserProLoadingState - ) { [weak manager] in manager?.loadingState } - } - - static func currentUserProStatus(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProStatus", - generic: .currentUserProStatus - ) { [weak manager] in manager?.proStatus } - } - - static func currentUserProAutoRenewing(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProAutoRenewing", - generic: .currentUserProAutoRenewing - ) { [weak manager] in manager?.autoRenewing } - } - - static func currentUserProAccessExpiryTimestampMs(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProAccessExpiryTimestampMs", - generic: .currentUserProAccessExpiryTimestampMs - ) { [weak manager] in manager?.accessExpiryTimestampMs } - } - - static func currentUserProLatestPaymentItem(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProLatestPaymentItem", - generic: .currentUserProLatestPaymentItem - ) { [weak manager] in manager?.latestPaymentItem } - } - - static func currentUserLatestPaymentOriginatingPlatform(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserLatestPaymentOriginatingPlatform", - generic: .currentUserLatestPaymentOriginatingPlatform - ) { [weak manager] in manager?.latestPaymentOriginatingPlatform } - } - - static func currentUserProOriginatingAccount(_ manager: SessionProManagerType) -> ObservableKey { - return ObservableKey.stream( - key: "currentUserProOriginatingAccount", - generic: .currentUserProOriginatingAccount - ) { [weak manager] in manager?.originatingAccount } - } - - static func currentUserProRefundingStatus(_ manager: SessionProManagerType) -> ObservableKey { + static func currentUserProState(_ manager: SessionProManagerType) -> ObservableKey { return ObservableKey.stream( - key: "currentUserProRefundingStatus", - generic: .currentUserProRefundingStatus - ) { [weak manager] in manager?.refundingStatus } + key: "currentUserProState", + generic: .currentUserProState + ) { [weak manager] in manager?.state } } } // stringlint:ignore_contents public extension GenericObservableKey { - static let buildVariant: GenericObservableKey = "buildVariant" - static let currentUserProLoadingState: GenericObservableKey = "currentUserProLoadingState" - static let currentUserProStatus: GenericObservableKey = "currentUserProStatus" - static let currentUserProAutoRenewing: GenericObservableKey = "currentUserProAutoRenewing" - static let currentUserProAccessExpiryTimestampMs: GenericObservableKey = "currentUserProAccessExpiryTimestampMs" - static let currentUserProLatestPaymentItem: GenericObservableKey = "currentUserProLatestPaymentItem" - static let currentUserLatestPaymentOriginatingPlatform: GenericObservableKey = "currentUserLatestPaymentOriginatingPlatform" - static let currentUserProOriginatingAccount: GenericObservableKey = "currentUserProOriginatingAccount" - static let currentUserProRefundingStatus: GenericObservableKey = "currentUserProRefundingStatus" + static let currentUserProState: GenericObservableKey = "currentUserProState" } // MARK: - Mocking private extension SessionProManager { - private func startProStatusObservations() { + private func startProMockingObservations() { proMockingObservationTask = ObservationBuilder - .initialValue(MockState(using: dependencies)) + .initialValue(SessionPro.MockState(using: dependencies)) .debounce(for: .milliseconds(10)) .using(dependencies: dependencies) - .query { previousValue, _, _, dependencies in - MockState(previousInfo: previousValue.info, using: dependencies) + .query { previousState, _, _, dependencies in + SessionPro.MockState(previousInfo: previousState.info, using: dependencies) } .assign { [weak self] state in Task.detached(priority: .userInitiated) { @@ -797,161 +818,31 @@ private extension SessionProManager { guard state.info.sessionProEnabled else { self?.syncState.update( rotatingKeyPair: .set(to: nil), - proStatus: .set(to: nil), - proProof: .set(to: nil), - proProfileFeatures: .set(to: .none) + state: .set(to: .invalid) ) - await self?.loadingStateStream.send(.loading) - await self?.proStatusStream.send(nil) - await self?.autoRenewingStream.send(nil) - await self?.accessExpiryTimestampMsStream.send(nil) - await self?.latestPaymentItemStream.send(nil) - await self?.sendUpdatedRefundingStatusState() + await self?.stateStream.send(.invalid) return } - let needsStateRefresh: Bool = { - /// We we just enabled Session Pro then we need to fetch the users state - if state.info.sessionProEnabled && state.previousInfo?.sessionProEnabled == false { - return true - } - - /// If any of the mock states changed from a mock to use the actual value then we need to know what the - /// actual value is (so need to refresh the state) - switch (state.previousInfo?.mockProBackendStatus, state.info.mockProBackendStatus) { - case (.simulate, .useActual): return true - default: break - } - - switch (state.previousInfo?.mockProLoadingState, state.info.mockProLoadingState) { - case (.simulate, .useActual): return true - default: break - } - - switch (state.previousInfo?.mockOriginatingPlatform, state.info.mockOriginatingPlatform) { - case (.simulate, .useActual): return true - default: break - } - - switch (state.previousInfo?.mockBuildVariant, state.info.mockBuildVariant) { - case (.simulate, .useActual): return true - default: break - } - - switch (state.previousInfo?.mockOriginatingAccount, state.info.mockOriginatingAccount) { - case (.simulate, .useActual): return true - default: break - } - - switch (state.previousInfo?.mockRefundingStatus, state.info.mockRefundingStatus) { - case (.simulate, .useActual): return true - default: break - } - - if (state.previousInfo?.mockAccessExpiryTimestamp ?? 0) > 0 && state.info.mockAccessExpiryTimestamp == 0 { - return true - } - - return false - }() - /// If we need a state refresh then start a new task to do so (we don't want the mocking to be dependant on the /// result of the refresh so don't wait for it to complete before doing any mock changes) - if needsStateRefresh { + if state.needsRefresh { Task.detached { [weak self] in try await self?.refreshProState() } } - /// While it would be easier to just rely on `refreshProState` to update the mocked statuses, that would + /// While it would be easier to just rely on `refreshProState` to update the mocked values, that would /// mean the mocking requires network connectivity which isn't ideal, so we also explicitly send out any mock /// changes separately - if state.info.mockProBackendStatus != state.previousInfo?.mockProBackendStatus { - switch state.info.mockProBackendStatus { - case .useActual: break - case .simulate(let value): - self?.syncState.update(proStatus: .set(to: value)) - await self?.proStatusStream.send(value) - await self?.sendUpdatedRefundingStatusState() - } - } - - if state.info.mockProLoadingState != state.previousInfo?.mockProLoadingState { - switch state.info.mockProLoadingState { - case .useActual: break - case .simulate(let value): await self?.loadingStateStream.send(value) - } - } - - if state.info.mockOriginatingPlatform != state.previousInfo?.mockOriginatingPlatform { - switch state.info.mockOriginatingPlatform { - case .useActual: break - case .simulate(let value): await self?.latestPaymentOriginatingPlatformStream.send(value) - } - } - - if state.info.mockBuildVariant != state.previousInfo?.mockBuildVariant { - switch state.info.mockBuildVariant { - case .useActual: break - case .simulate(let value): await self?.buildVariantStream.send(value) - } - } - - if state.info.mockOriginatingAccount != state.previousInfo?.mockOriginatingAccount { - switch state.info.mockOriginatingAccount { - case .useActual: break - case .simulate(let value): await self?.originatingAccountStream.send(value) - } - } + guard + let oldState: SessionPro.State = await self?.stateStream.getCurrent(), + let dependencies: Dependencies = self?.syncState.dependencies + else { return } - if state.info.mockRefundingStatus != state.previousInfo?.mockRefundingStatus { - switch state.info.mockRefundingStatus { - case .useActual: break - case .simulate(let value): await self?.refundingStatusStream.send(value) - } - } - - if state.info.mockAccessExpiryTimestamp != state.previousInfo?.mockAccessExpiryTimestamp { - if state.info.mockAccessExpiryTimestamp > 0 { - await self?.accessExpiryTimestampMsStream.send(UInt64(state.info.mockAccessExpiryTimestamp)) - } - } + let updatedState: SessionPro.State = oldState.with(using: dependencies) + self?.syncState.update(state: .set(to: updatedState)) + await self?.stateStream.send(updatedState) } } } - - private func mockedIfNeeded(_ value: SessionPro.LoadingState) -> SessionPro.LoadingState { - switch dependencies[feature: .mockCurrentUserSessionProLoadingState] { - case .simulate(let mockedValue): return mockedValue - case .useActual: return value - } - } - - private func mockedIfNeeded(_ value: Network.SessionPro.BackendUserProStatus) -> Network.SessionPro.BackendUserProStatus { - switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { - case .simulate(let mockedValue): return mockedValue - case .useActual: return value - } - } - - private func mockedIfNeeded(_ value: SessionProUI.ClientPlatform) -> SessionProUI.ClientPlatform { - switch dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform] { - case .simulate(let mockedValue): return mockedValue - case .useActual: return value - } - } - - private func mockedIfNeeded(_ value: SessionPro.RefundingStatus) -> SessionPro.RefundingStatus { - switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { - case .simulate(let mockedValue): return mockedValue - case .useActual: return value - } - } - - private func mockedIfNeeded(_ value: UInt64?) -> UInt64? { - let mockedValue: TimeInterval = dependencies[feature: .mockCurrentUserAccessExpiryTimestamp] - - guard mockedValue > 0 else { return value } - - return UInt64(mockedValue) - } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProExpiry.swift b/SessionMessagingKit/SessionPro/Types/SessionProExpiry.swift deleted file mode 100644 index 722700c58b..0000000000 --- a/SessionMessagingKit/SessionPro/Types/SessionProExpiry.swift +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUtilitiesKit - -public extension SessionPro { - enum Expiry: Sendable, CaseIterable, Equatable, CustomStringConvertible { - case tenSeconds - case twentyFourHoursMinusOneMinute - case twentyFourHoursPlusFiveMinute - case twentyFourDaysPlusFiveMinute - - public var durationInSeconds: TimeInterval { - switch self { - case .tenSeconds: return 10 - case .twentyFourHoursMinusOneMinute: return 24 * 60 * 60 - 60 - case .twentyFourHoursPlusFiveMinute: return 24 * 60 * 60 + 5 * 60 - case .twentyFourDaysPlusFiveMinute: return 24 * 24 * 60 * 60 + 5 * 60 - } - } - - public var description: String { - switch self { - case .tenSeconds: return "10s" - case .twentyFourHoursMinusOneMinute: return "23h59m" - case .twentyFourHoursPlusFiveMinute: return "24h+5m" - case .twentyFourDaysPlusFiveMinute: return "24d+5m" - } - } - } -} - -// MARK: - MockableFeature - -public extension FeatureStorage { - static let mockCurrentUserSessionProExpiry: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProExpiry" - ) -} - -extension SessionPro.Expiry: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .tenSeconds: return "The state where the users Pro status will expire in 10s" - case .twentyFourHoursMinusOneMinute: return "The state where the users Pro status will expire in 23h59m" - case .twentyFourHoursPlusFiveMinute: return "The state where the users Pro status will expire in 24h+5m" - case .twentyFourDaysPlusFiveMinute: return "The state where the users Pro status will expire in 24d+5m" - } - } -} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift index aae341b29e..856801904c 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift @@ -7,7 +7,7 @@ import SessionNetworkingKit import SessionUtilitiesKit public extension SessionPro { - struct Plan: Equatable, Sendable { + struct Plan: Sendable, Equatable, Hashable { // stringlint:ignore_contents private static let productIds: [String] = [ "com.getsession.org.pro_sub_1_month", @@ -28,49 +28,62 @@ public extension SessionPro { // MARK: - Functions - public static func retrievePlans() async throws -> [Plan] { + public static func retrieveProductsAndPlans() async throws -> (products: [Product], plans: [Plan]) { #if targetEnvironment(simulator) - return [ - Plan( - id: "SimId3", // stringlint:ignore - variant: .twelveMonths, - durationMonths: 12, - price: 111, - pricePerMonth: 9.25, - discountPercent: 75 - ), - Plan( - id: "SimId2", // stringlint:ignore - variant: .threeMonths, - durationMonths: 3, - price: 222, - pricePerMonth: 74, - discountPercent: 50 - ), - Plan( - id: "SimId1", // stringlint:ignore - variant: .oneMonth, - durationMonths: 1, - price: 444, - pricePerMonth: 444, - discountPercent: nil - ) - ] + return ( + [], + [ + Plan( + id: "SimId3", // stringlint:ignore + variant: .twelveMonths, + durationMonths: 12, + price: 111, + pricePerMonth: 9.25, + discountPercent: 75 + ), + Plan( + id: "SimId2", // stringlint:ignore + variant: .threeMonths, + durationMonths: 3, + price: 222, + pricePerMonth: 74, + discountPercent: 50 + ), + Plan( + id: "SimId1", // stringlint:ignore + variant: .oneMonth, + durationMonths: 1, + price: 444, + pricePerMonth: 444, + discountPercent: nil + ) + ] + ) #endif let products: [Product] = try await Product .products(for: productIds) .sorted() .reversed() - guard let shortestProductPrice: Decimal = products.last?.price else { - return [] + guard let shortestMonthlyPrice: Decimal = products.last.map({ $0.price / Decimal($0.durationMonths) }) else { + return ([], []) } - return products.map { product in + let plans: [Plan] = products.map { product in let durationMonths: Int = product.durationMonths - let priceDiff: Decimal = (shortestProductPrice - product.price) - let discountDecimal: Decimal = ((priceDiff / shortestProductPrice) * 100) - let discount: Int = Int(truncating: discountDecimal as NSNumber) + let thisMonthlyPrice: Decimal = (product.price / Decimal(durationMonths)) + let monthlySavings: Decimal = (shortestMonthlyPrice - thisMonthlyPrice) + let discountDecimal: Decimal = ((monthlySavings / shortestMonthlyPrice) * 100) + let discount: Int = NSDecimalNumber(decimal: discountDecimal) + .rounding(accordingToBehavior: NSDecimalNumberHandler( + roundingMode: .down, + scale: 0, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + )) + .intValue let variant: Network.SessionPro.Plan = { switch durationMonths { case 1: return .oneMonth @@ -91,6 +104,8 @@ public extension SessionPro { discountPercent: (variant != .oneMonth ? discount : nil) ) } + + return (products, plans) } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProState.swift b/SessionMessagingKit/SessionPro/Types/SessionProState.swift new file mode 100644 index 0000000000..522b579004 --- /dev/null +++ b/SessionMessagingKit/SessionPro/Types/SessionProState.swift @@ -0,0 +1,352 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import StoreKit +import SessionUIKit +import SessionNetworkingKit +import SessionUtilitiesKit + +public extension SessionPro { + struct State: Sendable, Equatable, Hashable { + public let sessionProEnabled: Bool + + public let buildVariant: BuildVariant + public let products: [Product] + public let plans: [SessionPro.Plan] + + public let loadingState: SessionPro.LoadingState + public let status: Network.SessionPro.BackendUserProStatus + public let proof: Network.SessionPro.ProProof? + public let profileFeatures: SessionPro.ProfileFeatures + + public let autoRenewing: Bool + public let accessExpiryTimestampMs: UInt64? + public let latestPaymentItem: Network.SessionPro.PaymentItem? + public let originatingPlatform: SessionProUI.ClientPlatform + public let originatingAccount: SessionPro.OriginatingAccount + public let refundingStatus: SessionPro.RefundingStatus + } +} + +public extension SessionPro.State { + static let invalid: SessionPro.State = SessionPro.State( + sessionProEnabled: false, + buildVariant: .appStore, + products: [], + plans: [], + loadingState: .loading, + status: .neverBeenPro, + proof: nil, + profileFeatures: .none, + autoRenewing: false, + accessExpiryTimestampMs: 0, + latestPaymentItem: nil, + originatingPlatform: .iOS, + originatingAccount: .originatingAccount, + refundingStatus: .notRefunding + ) +} + +internal extension SessionPro.State { + func with( + products: Update<[Product]> = .useExisting, + plans: Update<[SessionPro.Plan]> = .useExisting, + loadingState: Update = .useExisting, + status: Update = .useExisting, + proof: Update = .useExisting, + profileFeatures: Update = .useExisting, + autoRenewing: Update = .useExisting, + accessExpiryTimestampMs: Update = .useExisting, + latestPaymentItem: Update = .useExisting, + using dependencies: Dependencies + ) -> SessionPro.State { + let finalBuildVariant: BuildVariant = { + switch dependencies[feature: .mockCurrentUserSessionProBuildVariant] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return BuildVariant.current + } + }() + let finalLoadingState: SessionPro.LoadingState = { + switch dependencies[feature: .mockCurrentUserSessionProLoadingState] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return loadingState.or(self.loadingState) + } + }() + let finalStatus: Network.SessionPro.BackendUserProStatus = { + switch dependencies[feature: .mockCurrentUserSessionProBackendStatus] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return (status.or(self.status)) + } + }() + let finalAccessExpiryTimestampMs: UInt64? = { + let mockedValue: TimeInterval = dependencies[feature: .mockCurrentUserAccessExpiryTimestamp] + + guard mockedValue > 0 else { return accessExpiryTimestampMs.or(self.accessExpiryTimestampMs) } + + return UInt64(mockedValue) + }() + let finalLatestPaymentItem: Network.SessionPro.PaymentItem? = latestPaymentItem.or(self.latestPaymentItem) + let finalOriginatingPlatform: SessionProUI.ClientPlatform = { + switch dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform] { + case .simulate(let mockedValue): return mockedValue + case .useActual: return SessionProUI.ClientPlatform(finalLatestPaymentItem?.paymentProvider) + } + }() + +// // TODO: [PRO] 'originatingAccount'?? I think we might need to check StoreKit transactions to see if they match the current one? (and if not then it's not the originating account?) + + let finalRefundingStatus: SessionPro.RefundingStatus = { + switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { + case .simulate(let mockedValue): return mockedValue + case .useActual: + return SessionPro.RefundingStatus( + finalStatus == .active && + (finalLatestPaymentItem?.refundRequestedTimestampMs ?? 0) > 0 + ) + } + }() + + return SessionPro.State( + sessionProEnabled: dependencies[feature: .sessionProEnabled], + buildVariant: finalBuildVariant, + products: products.or(self.products), + plans: plans.or(self.plans), + loadingState: finalLoadingState, + status: finalStatus, + proof: proof.or(self.proof), + profileFeatures: profileFeatures.or(self.profileFeatures), + autoRenewing: autoRenewing.or(self.autoRenewing), + accessExpiryTimestampMs: finalAccessExpiryTimestampMs, + latestPaymentItem: finalLatestPaymentItem, + originatingPlatform: finalOriginatingPlatform, + originatingAccount: .originatingAccount, + refundingStatus: finalRefundingStatus + ) + } +} + +// MARK: - Convenience + +extension SessionProUI.ClientPlatform { + /// The originating platform the latest payment came from + /// + /// **Note:** There may not be a latest payment, in which case we default to `iOS` because we are on an `iOS` device + init(_ provider: Network.SessionPro.PaymentProvider?) { + switch provider { + case .none: self = .iOS + case .appStore: self = .iOS + case .playStore: self = .android + } + } +} + +// MARK: - SessionPro.MockState + +internal extension SessionPro { + struct MockState: ObservableKeyProvider { + struct Info: Sendable, Equatable { + let sessionProEnabled: Bool + let mockBuildVariant: MockableFeature + let mockProLoadingState: MockableFeature + let mockProBackendStatus: MockableFeature + let mockAccessExpiryTimestamp: TimeInterval + let mockOriginatingPlatform: MockableFeature + let mockOriginatingAccount: MockableFeature + let mockRefundingStatus: MockableFeature + } + + let previousInfo: Info? + let info: Info + + var needsRefresh: Bool { + guard let previousInfo else { return false } + + func changedToUseActual( + _ keyPath: KeyPath> + ) -> Bool { + switch (previousInfo[keyPath: keyPath], self.info[keyPath: keyPath]) { + case (.simulate, .useActual): return true + default: return false + } + } + + return ( + (info.sessionProEnabled && !previousInfo.sessionProEnabled) || + changedToUseActual(\.mockBuildVariant) || + changedToUseActual(\.mockProLoadingState) || + changedToUseActual(\.mockProBackendStatus) || + changedToUseActual(\.mockOriginatingPlatform) || + changedToUseActual(\.mockOriginatingAccount) || + changedToUseActual(\.mockRefundingStatus) || + (previousInfo.mockAccessExpiryTimestamp > 0 && info.mockAccessExpiryTimestamp == 0) + ) + } + + let observedKeys: Set = [ + .feature(.sessionProEnabled), + .feature(.mockCurrentUserSessionProBuildVariant), + .feature(.mockCurrentUserSessionProLoadingState), + .feature(.mockCurrentUserSessionProBackendStatus), + .feature(.mockCurrentUserAccessExpiryTimestamp), + .feature(.mockCurrentUserSessionProOriginatingPlatform), + .feature(.mockCurrentUserOriginatingAccount), + .feature(.mockCurrentUserSessionProRefundingStatus) + ] + + init(previousInfo: Info? = nil, using dependencies: Dependencies) { + self.previousInfo = previousInfo + self.info = Info( + sessionProEnabled: dependencies[feature: .sessionProEnabled], + mockBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], + mockProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], + mockProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], + mockAccessExpiryTimestamp: dependencies[feature: .mockCurrentUserAccessExpiryTimestamp], + mockOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], + mockOriginatingAccount: dependencies[feature: .mockCurrentUserOriginatingAccount], + mockRefundingStatus: dependencies[feature: .mockCurrentUserSessionProRefundingStatus] + ) + } + } +} + + +// MARK: - SessionPro.LoadingState + +public extension FeatureStorage { + static let mockCurrentUserSessionProLoadingState: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProLoadingState" + ) +} + +extension SessionPro.LoadingState: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .loading: return "The UI state while we are waiting on the network response." + case .error: return "The UI state when there was an error retrieving the users Pro status." + case .success: return "The UI state once we have successfully retrieved the users Pro status." + } + } +} + +// MARK: - Network.SessionPro.BackendUserProStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProBackendStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBackendStatus" + ) +} + +extension Network.SessionPro.BackendUserProStatus: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .neverBeenPro: return "The user has never had Session Pro before." + case .active: return "The user has an active Session Pro subscription." + case .expired: return "The user's Session Pro subscription has expired." + } + } +} + +// MARK: - Access Expiry Timestamp + +public extension FeatureStorage { + static let mockCurrentUserAccessExpiryTimestamp: FeatureConfig = Dependencies.create( + identifier: "mockCurrentUserAccessExpiryTimestamp" + ) +} + +// MARK: - SessionProUI.ClientPlatform + +public extension FeatureStorage { + static let mockCurrentUserSessionProOriginatingPlatform: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProOriginatingPlatform" + ) +} + +extension SessionProUI.ClientPlatform: @retroactive CustomStringConvertible { + public var description: String { + switch self { + case .iOS: return Constants.PaymentProvider.appStore.device + case .android: return Constants.PaymentProvider.playStore.device + } + } +} + +extension SessionProUI.ClientPlatform: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .iOS: return "The Session Pro subscription was originally purchased on an iOS device." + case .android: return "The Session Pro subscription was originally purchased on an Android device." + } + } +} + +// MARK: - OriginatingAccount.OriginatingAccount + +public extension FeatureStorage { + static let mockCurrentUserOriginatingAccount: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserOriginatingAccount" + ) +} + +extension SessionPro.OriginatingAccount: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .originatingAccount: return "The Session Pro subscription was originally purchased on the account currently logged in." + case .nonOriginatingAccount: return "The Session Pro subscription was originally purchased on a different account." + } + } +} + +// MARK: - BuildVariant + +public extension FeatureStorage { + static let mockCurrentUserSessionProBuildVariant: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProBuildVariant" + ) +} + +extension BuildVariant: @retroactive MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .appStore: return "The app was installed via the App Store." + case .development: return "The app is a development build." + case .testFlight: return "The app was installed via TestFlight." + case .ipa: return "The app was installed direcrtly as an IPA." + + case .apk: return "The app was installed directly as an APK." + case .fDroid: return "The app was installed via fDroid." + case .huawei: return "The app is a Huawei build." + } + } +} + +// MARK: - SessionPro.RefundingStatus + +public extension FeatureStorage { + static let mockCurrentUserSessionProRefundingStatus: FeatureConfig> = Dependencies.create( + identifier: "mockCurrentUserSessionProRefundingStatus" + ) +} + +extension SessionPro.RefundingStatus: MockableFeatureValue { + public var title: String { "\(self)" } + + public var subtitle: String { + switch self { + case .notRefunding: return "The Session Pro subscription does not currently have a pending refund." + case .refunding: return "The Session Pro subscription currently has a pending refund." + } + } +} diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift new file mode 100644 index 0000000000..8766c6c86e --- /dev/null +++ b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift @@ -0,0 +1,86 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit +import SessionNetworkingKit + +public extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { + init(state: SessionPro.State) { + let latestPlan: SessionPro.Plan? = state.plans.first { $0.variant == state.latestPaymentItem?.plan } + let expiryDate: Date? = state.accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } + + switch (state.status, latestPlan, state.refundingStatus) { + case (.neverBeenPro, _, _), (.active, .none, _): self = .purchase + case (.active, .some(let plan), .notRefunding): + self = .update( + currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), + expiredOn: (expiryDate ?? Date.distantPast), + originatingPlatform: state.originatingPlatform, + isAutoRenewing: (state.autoRenewing == true) + ) + + case (.expired, _, _): self = .renew(originatingPlatform: state.originatingPlatform) + case (.active, .some, .refunding): + self = .refund( + originatingPlatform: state.originatingPlatform, + isNonOriginatingAccount: (state.originatingAccount == .nonOriginatingAccount), + requestedAt: (state.latestPaymentItem?.refundRequestedTimestampMs).map { + Date(timeIntervalSince1970: (Double($0) / 1000)) + } + ) + } + } +} + +public extension SessionProPaymentScreenContent.SessionProPlanInfo { + init(plan: SessionPro.Plan) { + let price: Double = Double(truncating: plan.price as NSNumber) + let pricePerMonth: Double = Double(truncating: plan.pricePerMonth as NSNumber) + let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true)) + let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true)) + + self = SessionProPaymentScreenContent.SessionProPlanInfo( + id: plan.id, + duration: plan.durationMonths, + totalPrice: price, + pricePerMonth: pricePerMonth, + discountPercent: plan.discountPercent, + titleWithPrice: { + switch plan.variant { + case .none, .oneMonth: + return "proPriceOneMonth" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + + case .threeMonths: + return "proPriceThreeMonths" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + + case .twelveMonths: + return "proPriceTwelveMonths" + .put(key: "monthly_price", value: formattedPricePerMonth) + .localized() + } + }(), + subtitleWithPrice: { + switch plan.variant { + case .none, .oneMonth: + return "proBilledMonthly" + .put(key: "price", value: formattedPrice) + .localized() + + case .threeMonths: + return "proBilledQuarterly" + .put(key: "price", value: formattedPrice) + .localized() + + case .twelveMonths: + return "proBilledAnnually" + .put(key: "price", value: formattedPrice) + .localized() + } + }() + ) + } +} diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift b/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift deleted file mode 100644 index 12d2045f60..0000000000 --- a/SessionMessagingKit/SessionPro/Utilities/SessionProMocking.swift +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import SessionUIKit -import SessionNetworkingKit -import SessionUtilitiesKit - -// MARK: - SessionProManager.MockState - -internal extension SessionProManager { - struct MockState: ObservableKeyProvider { - struct Info: Sendable, Equatable { - let sessionProEnabled: Bool - let mockProLoadingState: MockableFeature - let mockProBackendStatus: MockableFeature - let mockOriginatingPlatform: MockableFeature - let mockBuildVariant: MockableFeature - let mockRefundingStatus: MockableFeature - } - - let previousInfo: Info? - let info: Info - - let observedKeys: Set = [ - .feature(.sessionProEnabled), - .feature(.mockCurrentUserSessionProLoadingState), - .feature(.mockCurrentUserSessionProBackendStatus), - .feature(.mockCurrentUserSessionProOriginatingPlatform), - .feature(.mockCurrentUserSessionProBuildVariant), - .feature(.mockCurrentUserSessionProRefundingStatus) - ] - - init(previousInfo: Info? = nil, using dependencies: Dependencies) { - self.previousInfo = previousInfo - self.info = Info( - sessionProEnabled: dependencies[feature: .sessionProEnabled], - mockProLoadingState: dependencies[feature: .mockCurrentUserSessionProLoadingState], - mockProBackendStatus: dependencies[feature: .mockCurrentUserSessionProBackendStatus], - mockOriginatingPlatform: dependencies[feature: .mockCurrentUserSessionProOriginatingPlatform], - mockBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], - mockRefundingStatus: dependencies[feature: .mockCurrentUserSessionProRefundingStatus] - ) - } - } -} - - -// MARK: - SessionPro.LoadingState - -public extension FeatureStorage { - static let mockCurrentUserSessionProLoadingState: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProLoadingState" - ) -} - -extension SessionPro.LoadingState: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .loading: return "The UI state while we are waiting on the network response." - case .error: return "The UI state when there was an error retrieving the users Pro status." - case .success: return "The UI state once we have successfully retrieved the users Pro status." - } - } -} - -// MARK: - Network.SessionPro.BackendUserProStatus - -public extension FeatureStorage { - static let mockCurrentUserSessionProBackendStatus: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProBackendStatus" - ) -} - -extension Network.SessionPro.BackendUserProStatus: @retroactive MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .neverBeenPro: return "The user has never had Session Pro before." - case .active: return "The user has an active Session Pro subscription." - case .expired: return "The user's Session Pro subscription has expired." - } - } -} - -// MARK: - SessionProUI.ClientPlatform - -public extension FeatureStorage { - static let mockCurrentUserSessionProOriginatingPlatform: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProOriginatingPlatform" - ) -} - -extension SessionProUI.ClientPlatform: @retroactive CustomStringConvertible { - public var description: String { - switch self { - case .iOS: return Constants.PaymentProvider.appStore.device - case .android: return Constants.PaymentProvider.playStore.device - } - } -} - -extension SessionProUI.ClientPlatform: @retroactive MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .iOS: return "The Session Pro subscription was originally purchased on an iOS device." - case .android: return "The Session Pro subscription was originally purchased on an Android device." - } - } -} - -// MARK: - SessionProUI.BuildVariant - -public extension FeatureStorage { - static let mockCurrentUserSessionProBuildVariant: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProBuildVariant" - ) -} - -extension SessionProUI.BuildVariant: @retroactive MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .apk: return "The app was installed directly as an APK." - case .fDroid: return "The app was installed via fDroid." - case .huawei: return "The app is a Huawei build." - case .ipa: return "The app was installed direcrtly as an IPA." - } - } -} - -// MARK: - SessionPro.RefundingStatus - -public extension FeatureStorage { - static let mockCurrentUserSessionProRefundingStatus: FeatureConfig> = Dependencies.create( - identifier: "mockCurrentUserSessionProRefundingStatus" - ) -} - -extension SessionPro.RefundingStatus: MockableFeatureValue { - public var title: String { "\(self)" } - - public var subtitle: String { - switch self { - case .notRefunding: return "The Session Pro subscription does not currently have a pending refund." - case .refunding: return "The Session Pro subscription currently has a pending refund." - } - } -} diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index b039ec6c85..a9da9d2800 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -49,6 +49,8 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif Date(timeIntervalSince1970: TimeInterval(Double(self.receivedAtTimestampMs) / 1000)) } + public var bodyTextColor: ThemeValue { MessageViewModel.bodyTextColor(isOutgoing: variant.isOutgoing) } + /// This value defines what type of cell should appear and is generated based on the interaction variant /// and associated attachment data public let cellType: CellType @@ -130,6 +132,9 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif /// This contains all sessionId values for the current user (standard and any blinded variants) public let currentUserSessionIds: Set + + /// This is the mention image for the current user + public let currentUserMentionImage: UIImage? } public extension MessageViewModel { @@ -202,6 +207,7 @@ public extension MessageViewModel { self.isLast = false self.isLastOutgoing = false self.currentUserSessionIds = [] + self.currentUserMentionImage = nil } init?( @@ -225,6 +231,7 @@ public extension MessageViewModel { nextInteraction: Interaction?, isLast: Bool, isLastOutgoing: Bool, + currentUserMentionImage: UIImage?, using dependencies: Dependencies ) { let targetId: Int64 @@ -348,7 +355,8 @@ public extension MessageViewModel { quotedInfo: nil, showProBadge: false, currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: { _, _ in nil } + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ) } @@ -434,7 +442,8 @@ public extension MessageViewModel { return profileCache[sessionId]?.displayName( includeSessionIdSuffix: (threadVariant == .community) ) - } + }, + currentUserMentionImage: currentUserMentionImage ) } self.linkPreview = linkPreviewInfo?.preview @@ -571,6 +580,7 @@ public extension MessageViewModel { self.isLast = isLast self.isLastOutgoing = isLastOutgoing self.currentUserSessionIds = currentUserSessionIds + self.currentUserMentionImage = currentUserMentionImage } func with( @@ -619,7 +629,8 @@ public extension MessageViewModel { isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, - currentUserSessionIds: currentUserSessionIds + currentUserSessionIds: currentUserSessionIds, + currentUserMentionImage: currentUserMentionImage ) } @@ -717,6 +728,13 @@ public extension MessageViewModel { extension MessageViewModel { private static let maxMinutesBetweenTwoDateBreaks: Int = 5 + public static func bodyTextColor(isOutgoing: Bool) -> ThemeValue { + return (isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + } + /// Returns the difference in minutes, ignoring seconds /// /// If both dates are the same date, returns 0 diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 29b05a8e30..1fb01999ee 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -166,7 +166,9 @@ public extension Profile { let proUpdate: TargetUserUpdate = { guard let targetFeatures: SessionPro.ProfileFeatures = proFeatures, - let proof: Network.SessionPro.ProProof = dependencies[singleton: .sessionProManager].currentUserCurrentProProof + let proof: Network.SessionPro.ProProof = dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .proof else { return .none } return .currentUserUpdate( diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 39785eb648..5c326f21ff 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -25,6 +25,7 @@ public extension Network.SessionPro { transactionId: "12345678", masterKeyPair: masterKeyPair, rotatingKeyPair: rotatingKeyPair, + requestTimeout: 5, using: dependencies ) let addProProofResponse = try await addProProofRequest @@ -78,6 +79,7 @@ public extension Network.SessionPro { transactionId: String, masterKeyPair: KeyPair, rotatingKeyPair: KeyPair, + requestTimeout: TimeInterval, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let cMasterPrivateKey: [UInt8] = masterKeyPair.secretKey @@ -115,6 +117,7 @@ public extension Network.SessionPro { using: dependencies ), responseType: AddProPaymentOrGenerateProProofResponse.self, + requestTimeout: requestTimeout, using: dependencies ) } diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 7e741a9273..18c98b6012 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -541,7 +541,53 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { mainStackView.spacing = 0 contentStackView.spacing = Values.verySmallSpacing proDescriptionLabelContainer.isHidden = (description == nil) - proDescriptionLabel.themeAttributedText = description + + if let description { + var result: ThemedAttributedString = ThemedAttributedString() + + if let attributedString: ThemedAttributedString = description.attributedString { + result.append(attributedString) + } + else if let text: String = description.text { + result.append(ThemedAttributedString(string: text)) + } + + switch description.accessory { + case .none: break + case .proBadgeLeading(let size, let themeBackgroundColor): + let proBadgeImage: UIImage = UIView.image( + for: .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), + generator: { SessionProBadge(size: size) } + ) + result.insert(ThemedAttributedString(string: " "), at: 0) + result.insert( + ThemedAttributedString( + image: proBadgeImage, + accessibilityLabel: SessionProBadge.accessibilityLabel, + font: proDescriptionLabel.font + ), + at: 0 + ) + + case .proBadgeTrailing(let size, let themeBackgroundColor): + let proBadgeImage: UIImage = UIView.image( + for: .themedKey(size.cacheKey, themeBackgroundColor: themeBackgroundColor), + generator: { SessionProBadge(size: size) } + ) + + result.append(ThemedAttributedString(string: " ")) + result.append( + ThemedAttributedString( + image: proBadgeImage, + accessibilityLabel: SessionProBadge.accessibilityLabel, + font: proDescriptionLabel.font + ) + ) + } + + proDescriptionLabel.themeAttributedText = result + } + imageViewContainer.isHidden = false profileView.clipsToBounds = (style == .circular) profileView.setDataManager(dataManager) @@ -1019,7 +1065,7 @@ public extension ConfirmationModal.Info { placeholder: ImageDataManager.DataSource?, icon: ProfilePictureView.Info.ProfileIcon = .none, style: ImageStyle, - description: ThemedAttributedString?, + description: SessionListScreenContent.TextInfo?, accessibility: Accessibility?, dataManager: ImageDataManagerType, onProBageTapped: (@MainActor () -> Void)?, diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 46bc7e631e..3269ba064e 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -3,11 +3,23 @@ import UIKit public class SessionProBadge: UIView { + public static let accessibilityLabel: String = Constants.app_pro + public static let identifier: String = "ProBadge" // stringlint:ignore public enum Size { case mini, small, medium, large + // stringlint:ignore_contents + public var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } + public var width: CGFloat { switch self { case .mini: return 24 diff --git a/SessionUIKit/Components/SwiftUI/AttributedText.swift b/SessionUIKit/Components/SwiftUI/AttributedText.swift index 0fc516159e..cdefa0aae8 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedText.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedText.swift @@ -27,7 +27,7 @@ public struct AttributedText: View { } private mutating func extractDescriptions() { - if let text = attributedText?.value { + if let text = attributedText?.attributedString { text.enumerateAttributes(in: NSMakeRange(0, text.length), options: [], using: { (attribute, range, stop) in let substring = (text.string as NSString).substring(with: range) let font = (attribute[.font] as? UIFont).map { Font($0) } diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift index b3a0bc129f..e6b803478a 100644 --- a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -63,7 +63,8 @@ public struct QuoteViewModel: Sendable, Equatable, Hashable { quotedInfo: nil, showProBadge: false, currentUserSessionIds: [], - displayNameRetriever: { _, _ in nil } + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ) public let mode: Mode @@ -120,7 +121,8 @@ public struct QuoteViewModel: Sendable, Equatable, Hashable { quotedInfo: QuotedInfo?, showProBadge: Bool, currentUserSessionIds: Set, - displayNameRetriever: @escaping DisplayNameRetriever + displayNameRetriever: @escaping DisplayNameRetriever, + currentUserMentionImage: UIImage? ) { self.mode = mode self.direction = direction @@ -162,7 +164,8 @@ public struct QuoteViewModel: Sendable, Equatable, Hashable { .themeForegroundColor: targetThemeColor, .font: UIFont.systemFont(ofSize: Values.smallFontSize) ], - displayNameRetriever: displayNameRetriever + displayNameRetriever: displayNameRetriever, + currentUserMentionImage: currentUserMentionImage ) } @@ -358,7 +361,8 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { ), showProBadge: true, currentUserSessionIds: ["05123"], - displayNameRetriever: { _, _ in nil } + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) @@ -386,7 +390,8 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { ), showProBadge: false, currentUserSessionIds: [], - displayNameRetriever: { _, _ in "Some User" } + displayNameRetriever: { _, _ in "Some User" }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) @@ -421,7 +426,8 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { ), showProBadge: false, currentUserSessionIds: [], - displayNameRetriever: { _, _ in nil } + displayNameRetriever: { _, _ in nil }, + currentUserMentionImage: nil ), dataManager: ImageDataManager() ) diff --git a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift index 19a209bee7..c1c327881d 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListContentModels/SessionListScreen+Models.swift @@ -33,8 +33,14 @@ public extension SessionListScreenContent { struct TextInfo: Hashable, Equatable { public enum Accessory: Hashable, Equatable { - case proBadgeLeading(themeBackgroundColor: ThemeValue) - case proBadgeTrailing(themeBackgroundColor: ThemeValue) + case proBadgeLeading( + size: SessionProBadge.Size, + themeBackgroundColor: ThemeValue + ) + case proBadgeTrailing( + size: SessionProBadge.Size, + themeBackgroundColor: ThemeValue + ) case none } diff --git a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift index 381017ac87..99f5ad3295 100644 --- a/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift +++ b/SessionUIKit/Screens/SessionListScreen/ListItemViews/SessionListScreen+ListItemCell.swift @@ -37,8 +37,8 @@ public struct ListItemCell: View { VStack(alignment: .leading, spacing: 0) { if let title = info.title { HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeLeading(let size, let themeBackgroundColor) = title.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } if let text = title.text { @@ -57,16 +57,16 @@ public struct ListItemCell: View { .fixedSize() } - if case .proBadgeTrailing(let themeBackgroundColor) = title.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeTrailing(let size, let themeBackgroundColor) = title.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } } } if let description = info.description { HStack(spacing: Values.verySmallSpacing) { - if case .proBadgeLeading(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeLeading(let size, let themeBackgroundColor) = description.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } if let text = description.text { @@ -85,8 +85,8 @@ public struct ListItemCell: View { .fixedSize(horizontal: false, vertical: true) } - if case .proBadgeTrailing(let themeBackgroundColor) = description.accessory { - SessionProBadge_SwiftUI(size: .mini, themeBackgroundColor: themeBackgroundColor) + if case .proBadgeTrailing(let size, let themeBackgroundColor) = description.accessory { + SessionProBadge_SwiftUI(size: size, themeBackgroundColor: themeBackgroundColor) } } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index 08734dbf97..990e11b188 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -77,6 +77,7 @@ public extension SessionProPaymentScreenContent { } struct SessionProPlanInfo: Equatable { + public let id: String public let duration: Int let totalPrice: Double let pricePerMonth: Double @@ -104,6 +105,7 @@ public extension SessionProPaymentScreenContent { } public init( + id: String, duration: Int, totalPrice: Double, pricePerMonth: Double, @@ -111,6 +113,7 @@ public extension SessionProPaymentScreenContent { titleWithPrice: String, subtitleWithPrice: String ) { + self.id = id self.duration = duration self.totalPrice = totalPrice self.pricePerMonth = pricePerMonth @@ -139,12 +142,13 @@ public extension SessionProPaymentScreenContent { protocol ViewModelType: AnyObject { var dataModel: DataModel { get set } + var dateNow: Date { get } var isRefreshing: Bool { get set } var errorString: String? { get set } - func purchase(planInfo: SessionProPlanInfo, success: (() -> Void)?, failure: (() -> Void)?) - func cancelPro(success: (() -> Void)?, failure: (() -> Void)?) - func requestRefund(success: (() -> Void)?, failure: (() -> Void)?) + @MainActor func purchase(planInfo: SessionProPlanInfo, success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) + @MainActor func cancelPro(success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) + @MainActor func requestRefund(success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) func openURL(_ url: URL) } } diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift index 8b9fe60866..948b2bc2ae 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Purchase.swift @@ -18,6 +18,12 @@ struct SessionProPlanPurchaseContent: View { let purchaseAction: () -> Void let openTosPrivacyAction: () -> Void + var isCurrentPlanSelected: Bool { + guard currentSelection < sessionProPlans.count else { return false } + + return (sessionProPlans[currentSelection] == currentPlan) + } + // TODO: [PRO] Do we need a loading state in case the plans aren't loaded yet? var body: some View { VStack(spacing: Values.mediumSmallSpacing) { ForEach(sessionProPlans.indices, id: \.self) { index in @@ -45,14 +51,15 @@ struct SessionProPlanPurchaseContent: View { .background( RoundedRectangle(cornerRadius: 7) .fill( - themeColor: (sessionProPlans[currentSelection] == currentPlan) ? + themeColor: (isCurrentPlanSelected ? .disabled : .sessionButton_primaryFilledBackground + ) ) ) .padding(.vertical, Values.smallSpacing) } - .disabled((sessionProPlans[currentSelection] == currentPlan)) + .disabled(isCurrentPlanSelected) AttributedText( "noteTosPrivacyPolicy" diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 1aa2870f24..904e62e80c 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -62,6 +62,7 @@ public struct SessionProPaymentScreen: View { ) case .renew(let originatingPlatform): + // TODO: [PRO] Should this check the build variant? if viewModel.dataModel.plans.isEmpty { RenewPlanNoBillingAccessContent( originatingPlatform: originatingPlatform, @@ -196,59 +197,63 @@ public struct SessionProPaymentScreen: View { } private func updatePlan() { - let updatedPlan = viewModel.dataModel.plans[currentSelection] - if - case .update(let currentPlan, let expiredOn, _, let isAutoRenewing) = viewModel.dataModel.flow, - let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) - { - let confirmationModal = ConfirmationModal( - info: .init( - title: "updateAccess" - .put(key: "pro", value: Constants.pro) - .localized(), - body: .attributedText( - isAutoRenewing ? - "proUpdateAccessDescription" - .put(key: "current_plan_length", value: currentPlan.durationString) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular) : - "proUpdateAccessExpireDescription" - .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) - .put(key: "selected_plan_length", value: updatedPlan.durationString) - .put(key: "pro", value: Constants.pro) - .localizedFormatted(Fonts.Body.largeRegular), - scrollMode: .never - ), - confirmTitle: "update".localized(), - onConfirm: { _ in - self.viewModel.purchase( - planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, - failure: { - // TODO: Payment failure behaviour - } - ) - } - ) - ) - self.host.controller?.present(confirmationModal, animated: true) - } + let updatedPlan: SessionProPaymentScreenContent.SessionProPlanInfo = viewModel.dataModel.plans[currentSelection] switch viewModel.dataModel.flow { + case .refund, .cancel: break case .purchase, .renew: - if let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: Date()) { - self.viewModel.purchase( - planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, - failure: { - // TODO: Payment failure behaviour + let updatedPlanExpiredOn: Date = (Calendar.current + .date(byAdding: .month, value: updatedPlan.duration, to: viewModel.dateNow) ?? + viewModel.dateNow + ) + + self.viewModel.purchase( + planInfo: updatedPlan, + success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, + failure: { + // TODO: [PRO] Payment failure behaviour + } + ) + + case .update(let currentPlan, let expiredOn, _, let isAutoRenewing): + let updatedPlanExpiredOn: Date = (Calendar.current + .date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) ?? + expiredOn) + + let confirmationModal = ConfirmationModal( + info: .init( + title: "updateAccess" + .put(key: "pro", value: Constants.pro) + .localized(), + body: .attributedText( + isAutoRenewing ? + "proUpdateAccessDescription" + .put(key: "current_plan_length", value: currentPlan.durationString) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular) + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular) : + "proUpdateAccessExpireDescription" + .put(key: "date", value: expiredOn.formatted("MMM dd, yyyy")) + .put(key: "selected_plan_length", value: updatedPlan.durationString) + .put(key: "pro", value: Constants.pro) + .localizedFormatted(Fonts.Body.largeRegular), + scrollMode: .never + ), + confirmTitle: "update".localized(), + onConfirm: { _ in + self.viewModel.purchase( + planInfo: updatedPlan, + success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, + failure: { + // TODO: [PRO] Payment failure behaviour + } + ) } ) - } - default: break + ) + self.host.controller?.present(confirmationModal, animated: true) } } diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index 2f984aa0b8..f506dab556 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -405,10 +405,11 @@ public enum ThemeManager { } let newAttrString: NSMutableAttributedString = NSMutableAttributedString() - let fullRange: NSRange = NSRange(location: 0, length: originalThemedString.value.length) + let originalAttrString: NSAttributedString = originalThemedString.attributedString + let fullRange: NSRange = NSRange(location: 0, length: originalAttrString.length) let currentState: ThemeState = syncState.state - originalThemedString.value.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in + originalAttrString.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in var newAttributes: [NSAttributedString.Key: Any] = attributes /// Convert any of our custom attributes to their normal ones @@ -428,7 +429,7 @@ public enum ThemeManager { } /// Add the themed substring to `newAttrString` - let substring: String = originalThemedString.value.attributedSubstring(from: range).string + let substring: String = originalAttrString.attributedSubstring(from: range).string newAttrString.append(NSAttributedString(string: substring, attributes: newAttributes)) } diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 1e0f551b76..42cad3d47e 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -35,47 +35,23 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha private let lock: NSLock = NSLock() private let _attributedString: NSMutableAttributedString - internal let imageAttachmentGenerator: (@Sendable () -> (UIImage, String?)?)? - internal let imageAttachmentReferenceFont: UIFont? internal var attributedString: NSAttributedString { lock.lock() defer { lock.unlock() } return _attributedString } - internal var value: NSAttributedString { - if let (image, accessibilityLabel) = imageAttachmentGenerator?() { - let attachment: NSTextAttachment = NSTextAttachment(image: image) - attachment.accessibilityLabel = accessibilityLabel /// Ensure it's still visible to accessibility inspectors - - if let font = imageAttachmentReferenceFont { - attachment.bounds = CGRect( - x: 0, - y: font.capHeight / 2 - image.size.height / 2, - width: image.size.width, - height: image.size.height - ) - } - - return NSAttributedString(attachment: attachment) - } - - return attributedString - } - public var string: String { value.string } - public var length: Int { value.length } + public var string: String { attributedString.string } /// It seems that a number of UI elements don't properly check the `NSTextAttachment.accessibilityLabel` when /// constructing their accessibility label, as such we need to construct our own which includes that content public var constructedAccessibilityLabel: String { - lock.lock() - defer { lock.unlock() } - let result: NSMutableString = NSMutableString() - let rawString: String = value.string - let fullRange: NSRange = NSRange(location: 0, length: self.length) + let attrString: NSAttributedString = attributedString + let rawString: String = attrString.string + let fullRange: NSRange = NSRange(location: 0, length: attrString.length) - value.enumerateAttributes( + attrString.enumerateAttributes( in: fullRange, options: [] ) { attributes, range, stop in @@ -99,14 +75,10 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha public init() { self._attributedString = NSMutableAttributedString() - self.imageAttachmentGenerator = nil - self.imageAttachmentReferenceFont = nil } public init(attributedString: ThemedAttributedString) { self._attributedString = attributedString._attributedString - self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator - self.imageAttachmentReferenceFont = attributedString.imageAttachmentReferenceFont } public init(attributedString: NSAttributedString) { @@ -114,8 +86,6 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha ThemedAttributedString.validateAttributes(attributedString) #endif self._attributedString = NSMutableAttributedString(attributedString: attributedString) - self.imageAttachmentGenerator = nil - self.imageAttachmentReferenceFont = nil } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { @@ -123,8 +93,6 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha ThemedAttributedString.validateAttributes(attributes) #endif self._attributedString = NSMutableAttributedString(string: string, attributes: attributes) - self.imageAttachmentGenerator = nil - self.imageAttachmentReferenceFont = nil } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { @@ -132,14 +100,10 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha ThemedAttributedString.validateAttributes(attributes) #endif self._attributedString = NSMutableAttributedString(attachment: attachment) - self.imageAttachmentGenerator = nil - self.imageAttachmentReferenceFont = nil } public init(imageAttachmentGenerator: @escaping (@Sendable () -> (UIImage, String?)?), referenceFont: UIFont?) { self._attributedString = NSMutableAttributedString() - self.imageAttachmentGenerator = imageAttachmentGenerator - self.imageAttachmentReferenceFont = referenceFont } required init?(coder: NSCoder) { @@ -147,17 +111,32 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha } public static func == (lhs: ThemedAttributedString, rhs: ThemedAttributedString) -> Bool { - return lhs.value == rhs.value + return lhs.attributedString == rhs.attributedString } public func hash(into hasher: inout Hasher) { - value.hash(into: &hasher) + attributedString.hash(into: &hasher) } // MARK: - Forwarded Functions public func attributedSubstring(from range: NSRange) -> ThemedAttributedString { - return ThemedAttributedString(attributedString: value.attributedSubstring(from: range)) + return ThemedAttributedString(attributedString: attributedString.attributedSubstring(from: range)) + } + + public func insert(_ attributedString: NSAttributedString, at location: Int) { + #if DEBUG + ThemedAttributedString.validateAttributes(attributedString) + #endif + lock.lock() + defer { lock.unlock() } + self._attributedString.insert(attributedString, at: location) + } + + public func insert(_ other: ThemedAttributedString, at location: Int) { + lock.lock() + defer { lock.unlock() } + self._attributedString.insert(other.attributedString, at: location) } public func appending(string: String, attributes: [NSAttributedString.Key: Any]? = nil) -> ThemedAttributedString { @@ -179,10 +158,10 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha self._attributedString.append(attributedString) } - public func append(_ attributedString: ThemedAttributedString) { + public func append(_ other: ThemedAttributedString) { lock.lock() defer { lock.unlock() } - self._attributedString.append(attributedString.value) + self._attributedString.append(other.attributedString) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { @@ -195,18 +174,18 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha return self } - public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { + public func appending(_ other: ThemedAttributedString) -> ThemedAttributedString { lock.lock() defer { lock.unlock() } - self._attributedString.append(attributedString.value) + self._attributedString.append(other.attributedString) return self } public func addAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) { #if DEBUG - ThemedAttributedString.validateAttributes([name: value]) + ThemedAttributedString.validateAttributes([name: attributedString]) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) lock.lock() defer { lock.unlock() } self._attributedString.addAttribute(name, value: attrValue, range: targetRange) @@ -214,9 +193,9 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { #if DEBUG - ThemedAttributedString.validateAttributes([name: value]) + ThemedAttributedString.validateAttributes([name: attributedString]) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) lock.lock() defer { lock.unlock() } self._attributedString.addAttribute(name, value: attrValue, range: targetRange) @@ -227,7 +206,7 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha #if DEBUG ThemedAttributedString.validateAttributes(attrs) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) lock.lock() defer { lock.unlock() } self._attributedString.addAttributes(attrs, range: targetRange) @@ -237,7 +216,7 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha #if DEBUG ThemedAttributedString.validateAttributes(attrs) #endif - let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) + let targetRange: NSRange = (range ?? NSRange(location: 0, length: attributedString.length)) lock.lock() defer { lock.unlock() } self._attributedString.addAttributes(attrs, range: targetRange) @@ -292,3 +271,25 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha } #endif } + +public extension ThemedAttributedString { + convenience init( + image: UIImage, + accessibilityLabel: String?, + font: UIFont? = nil + ) { + let attachment: NSTextAttachment = NSTextAttachment(image: image) + attachment.accessibilityLabel = accessibilityLabel /// Ensure it's still visible to accessibility inspectors + + if let font { + attachment.bounds = CGRect( + x: 0, + y: font.capHeight / 2 - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) + } + + self.init(attachment: attachment) + } +} diff --git a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift index 885320824d..cc84c5c137 100644 --- a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift @@ -520,7 +520,7 @@ extension DirectAttributedTextAssignable { extension AttributedTextAssignable { private var themeAttributedTextValue: ThemedAttributedString? { get { attributedTextValue.map { ThemedAttributedString(attributedString: $0) } } - set { attributedTextValue = newValue?.value } + set { attributedTextValue = newValue?.attributedString } } @MainActor public var themeAttributedText: ThemedAttributedString? { set { ThemeManager.set(self, keyPath: \.themeAttributedTextValue, to: newValue) } @@ -532,7 +532,7 @@ extension UILabel: DirectAttributedTextAssignable {} extension UITextField: DirectAttributedTextAssignable { private var themeAttributedPlaceholderValue: ThemedAttributedString? { get { attributedPlaceholder.map { ThemedAttributedString(attributedString: $0) } } - set { attributedPlaceholder = newValue?.value } + set { attributedPlaceholder = newValue?.attributedString } } @MainActor public var themeAttributedPlaceholder: ThemedAttributedString? { set { ThemeManager.set(self, keyPath: \.themeAttributedPlaceholderValue, to: newValue) } diff --git a/SessionUIKit/Types/BuildVariant.swift b/SessionUIKit/Types/BuildVariant.swift index c9e0e20c59..7958b20f2f 100644 --- a/SessionUIKit/Types/BuildVariant.swift +++ b/SessionUIKit/Types/BuildVariant.swift @@ -20,7 +20,7 @@ public enum BuildVariant: Sendable, Equatable, CaseIterable, CustomStringConvert let hasProvisioningProfile: Bool = (Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") != nil) let receiptUrl: URL? = Bundle.main.appStoreReceiptURL - let hasSandboxReceipt: Bool = (receiptURL?.lastPathComponent == "sandboxReceipt") + let hasSandboxReceipt: Bool = (receiptUrl?.lastPathComponent == "sandboxReceipt") if !hasProvisioningProfile { return .appStore diff --git a/SessionUIKit/Types/SessionProUIManagerType.swift b/SessionUIKit/Types/SessionProUIManagerType.swift index 35633a47a8..99e18eab82 100644 --- a/SessionUIKit/Types/SessionProUIManagerType.swift +++ b/SessionUIKit/Types/SessionProUIManagerType.swift @@ -9,7 +9,6 @@ public protocol SessionProUIManagerType: Actor { nonisolated var currentUserIsPro: AsyncStream { get } nonisolated func numberOfCharactersLeft(for content: String) -> Int - func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, @@ -19,6 +18,8 @@ public protocol SessionProUIManagerType: Actor { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool + + func purchasePro(productId: String) async throws } // MARK: - Convenience @@ -67,10 +68,6 @@ internal actor NoopSessionProUIManager: SessionProUIManagerType { nonisolated public func numberOfCharactersLeft(for content: String) -> Int { 0 } - public func upgradeToPro(completion: ((_ result: Bool) -> Void)?) async { - completion?(false) - } - @discardableResult @MainActor func showSessionProCTAIfNeeded( _ variant: ProCTAModal.Variant, dismissType: Modal.DismissType, @@ -79,4 +76,6 @@ internal actor NoopSessionProUIManager: SessionProUIManagerType { ) -> Bool { return false } + + public func purchasePro(productId: String) async throws {} } diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 57c6d7f15b..3843fa7369 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -8,6 +8,8 @@ public typealias DisplayNameRetriever = (_ sessionId: String, _ inMessageBody: B public enum MentionUtilities { private static let currentUserCacheKey: String = "Mention.CurrentUser" // stringlint:ignore private static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) + private static let mentionFont: UIFont = .boldSystemFont(ofSize: Values.smallFontSize) + private static let currentUserMentionImageSizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) public enum MentionLocation { case incomingMessage @@ -26,6 +28,25 @@ public enum MentionUtilities { .compactMap { match in Range(match.range, in: string).map { String(string[$0]) } }) } + @MainActor public static func generateCurrentUserMentionImage(textColor: ThemeValue) -> UIImage { + return UIView.image( + for: .themedKey( + MentionUtilities.currentUserCacheKey, + themeBackgroundColor: .primary + ), + generator: { + HighlightMentionView( + mentionText: "@\("you".localized())", // stringlint:ignore + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * currentUserMentionImageSizeDiff), + backgroundPadding: (3 * currentUserMentionImageSizeDiff) + ) + } + ) + } + public static func getMentions( in string: String, currentUserSessionIds: Set, @@ -109,7 +130,8 @@ public enum MentionUtilities { location: MentionLocation, textColor: ThemeValue, attributes: [NSAttributedString.Key: Any], - displayNameRetriever: DisplayNameRetriever + displayNameRetriever: DisplayNameRetriever, + currentUserMentionImage: UIImage? ) -> ThemedAttributedString { let (string, mentions) = getMentions( in: string, @@ -117,41 +139,22 @@ public enum MentionUtilities { displayNameRetriever: displayNameRetriever ) - let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) let result = ThemedAttributedString(string: string, attributes: attributes) - let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) + // Iterate in reverse so index ranges remain valid while replacing for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { - if mention.isCurrentUser && location == .incomingMessage { - // Build the rendered chip image - let image: UIImage = UIView.image( - for: .themedKey( - MentionUtilities.currentUserCacheKey, - themeBackgroundColor: .primary - ), - generator: { - HighlightMentionView( - mentionText: (result.string as NSString).substring(with: mention.range), - font: mentionFont, - themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), - themeBackgroundColor: .primary, - backgroundCornerRadius: (8 * sizeDiff), - backgroundPadding: (3 * sizeDiff) - ) - } - ) - + if mention.isCurrentUser && location == .incomingMessage, let currentUserMentionImage { /// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors let attachment: NSTextAttachment = NSTextAttachment() - attachment.accessibilityLabel = (result.string as NSString).substring(with: mention.range) + attachment.accessibilityLabel = (result.attributedString.string as NSString).substring(with: mention.range) - let offsetY: CGFloat = (mentionFont.capHeight - image.size.height) / 2 - attachment.image = image + let offsetY: CGFloat = (mentionFont.capHeight - currentUserMentionImage.size.height) / 2 + attachment.image = currentUserMentionImage attachment.bounds = CGRect( x: 0, y: offsetY, - width: image.size.width, - height: image.size.height + width: currentUserMentionImage.size.width, + height: currentUserMentionImage.size.height ) let attachmentString = NSMutableAttributedString(attachment: attachment) @@ -160,8 +163,8 @@ public enum MentionUtilities { result.replaceCharacters(in: mention.range, with: attachmentString) let insertIndex = mention.range.location + attachmentString.length - if insertIndex < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) + if insertIndex < result.attributedString.length { + result.addAttribute(.kern, value: (3 * currentUserMentionImageSizeDiff), range: NSRange(location: insertIndex, length: 1)) } continue } diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 6ef30de1d8..699fd6675f 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -4,7 +4,7 @@ import UIKit public extension UILabel { /// Appends a rendered snapshot of `view` as an inline image attachment. - func attachTrailing( + @MainActor func attachTrailing( cacheKey: CachedImageKey?, accessibilityLabel: String? = nil, viewGenerator: (() -> UIView)?, @@ -15,20 +15,20 @@ public extension UILabel { let base = ThemedAttributedString() if let existing = attributedText, existing.length > 0 { base.append(existing) - } else if let t = text { + } + else if let t = text { base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) } + let image: UIImage = UIView.image(for: cacheKey, generator: viewGenerator) base.append(NSAttributedString(string: spacing)) - base.append(ThemedAttributedString( - imageAttachmentGenerator: { - ( - UIView.image(for: cacheKey, generator: viewGenerator), - accessibilityLabel - ) - }, - referenceFont: font - )) + base.append( + ThemedAttributedString( + image: image, + accessibilityLabel: accessibilityLabel, + font: font + ) + ) themeAttributedText = base numberOfLines = 0 diff --git a/SessionUIKit/Utilities/UIView+Utilities.swift b/SessionUIKit/Utilities/UIView+Utilities.swift index 4c584100eb..e454ff70ba 100644 --- a/SessionUIKit/Utilities/UIView+Utilities.swift +++ b/SessionUIKit/Utilities/UIView+Utilities.swift @@ -18,7 +18,7 @@ public extension UIView { } } - static func image( + @MainActor static func image( for key: CachedImageKey, generator: () -> UIView ) -> UIImage { From 57305ede0ade882092d6c2697304ce70ae7ae0ac Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 2 Dec 2025 16:26:57 +1100 Subject: [PATCH 31/66] Updated with latest libSession changes --- SessionMessagingKit/Crypto/Crypto+LibSession.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 419d23fc92..77b1904f82 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -141,7 +141,6 @@ public extension Crypto.Generator { ) { dependencies in let cEncodedMessage: [UInt8] = Array(encodedMessage) let cBackendPubkey: [UInt8] = Array(Data(hex: Network.SessionPro.serverEdPublicKey)) - let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var error: [CChar] = [CChar](repeating: 0, count: 256) switch origin { @@ -149,10 +148,11 @@ public extension Crypto.Generator { /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy /// structure) then we try to decode as the legacy structure (which succeeds) + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( cEncodedMessage, cEncodedMessage.count, - currentTimestampMs, + sentTimestampMs, cBackendPubkey, cBackendPubkey.count, &error, @@ -182,10 +182,11 @@ public extension Crypto.Generator { /// **Note:** This will generate an error in the debug console because we are slowly migrating the structure of /// Community protobuf content, first we try to decode as an envelope (which logs this error when it's the legacy /// structure) then we try to decode as the legacy structure (which succeeds) + let sentTimestampMs: UInt64 = UInt64(floor(posted * 1000)) var cResult: session_protocol_decoded_community_message = session_protocol_decode_for_community( cPlaintext, cPlaintext.count, - currentTimestampMs, + sentTimestampMs, cBackendPubkey, cBackendPubkey.count, &error, @@ -255,7 +256,6 @@ public extension Crypto.Generator { &cKeys, cEncodedMessage, cEncodedMessage.count, - currentTimestampMs, cBackendPubkey, cBackendPubkey.count, &error, From ac9816b1183d23fb37ca495c4a678443c217325b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 11 Dec 2025 16:47:37 +1100 Subject: [PATCH 32/66] Massive refactors of the SessionThreadViewModel and MessageViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `SessionThreadViewModel` has been renamed to `ConversationInfoViewModel` and is now a simple struct (as opposed to a FetchableRecord with a handful of behemoth database queries) The `ConversationInfoViewModel` and `MessageViewModel` now get initialised with a `ConversationDataCache` which is an object that holds a bunch of [ID -> Data] stores for various types that are needed to construct the aforementioned view models. The `ConversationDataCache` will generally be populated via the `ConversationDataHelper` which, when combined with our `ObservableEvent` system, is where the majority of the work for this refactoring has gone. This helper essentially has 4 parts: - `determineFetchRequirements`: Reviews any events which have come through and determines whether anything needs to be fetched from the database - `applyNonDatabaseEvents`: Updates the cached data directly based on delta information included in the `ObservableEvent` instances - `fetchFromDatabase`: Fetches any data from the database based on the info returned from `determineFetchRequirements`, this function will also resolve any dependant data that newly fetched objects need if they aren't already in the cache - `fetchFromLibSession`: Retrieves requested data from libSession Using the above setup all screens which contain conversation or message data now use these two view models, and can share the state of the view models between each other to reduce the need to refetch data when entering a new screen Some additional changes included due to the above are: • No longer need to pass `displayNameRetriever` throughout • Removed deprecated display name retrieval functions • Removed some duplicate code • Removed some synchronous database query hacks we needed until refactoring had been completed • Refactored mentions to be handled by tags • Refactored and optimised the searching logic (also added some caching) • Refactored the ThreadSettingsViewModel to use the new ObservationBuilder system • Refactored the ThreadPickerViewModel to use the new ObservationBuilder system • Refactored the HomeViewModel to retrieve data using the `ConversationDataHelper` • Refactored the ConversationViewModel to retrieve data using the `ConversationDataHelper` • Fixed an issue where communities weren't getting the correct "joined" timestamp --- Session.xcodeproj/project.pbxproj | 56 +- .../Calls/Call Management/SessionCall.swift | 2 +- Session/Calls/WebRTC/WebRTCSession.swift | 56 +- .../Context Menu/ContextMenuVC+Action.swift | 14 +- .../Conversations/ConversationSearch.swift | 2 +- .../ConversationVC+Interaction.swift | 320 ++- Session/Conversations/ConversationVC.swift | 129 +- .../Conversations/ConversationViewModel.swift | 1113 +++----- .../Message Cells/CallMessageCell.swift | 3 +- .../Message Cells/DateHeaderCell.swift | 1 - .../Message Cells/InfoMessageCell.swift | 3 +- .../Message Cells/MessageCell.swift | 1 - .../Message Cells/TypingIndicatorCell.swift | 1 - .../Message Cells/UnreadMarkerCell.swift | 1 - .../Message Cells/VisibleMessageCell.swift | 97 +- ...isappearingMessagesSettingsViewModel.swift | 16 +- .../Settings/ThreadSettingsViewModel.swift | 627 +++-- .../ConversationTitleView.swift | 25 +- Session/Emoji/EmojiWithSkinTones.swift | 1 + .../GlobalSearchViewController.swift | 307 ++- Session/Home/HomeVC.swift | 53 +- Session/Home/HomeViewModel.swift | 456 ++-- .../MessageRequestsViewModel.swift | 339 +-- .../MessageInfoScreen.swift | 127 +- Session/Meta/AppDelegate.swift | 36 +- Session/Meta/SessionApp.swift | 13 +- .../Notifications/NotificationPresenter.swift | 14 +- Session/Onboarding/LandingScreen.swift | 1 + Session/Onboarding/Onboarding.swift | 1 + Session/Open Groups/JoinOpenGroupVC.swift | 1 + Session/Settings/NukeDataModal.swift | 30 +- .../SessionProSettingsViewModel.swift | 8 +- Session/Settings/SettingsViewModel.swift | 2 +- .../Views/ThemeMessagePreviewView.swift | 2 - Session/Shared/FullConversationCell.swift | 384 +-- .../Shared/SessionTableViewController.swift | 2 +- .../Views/SessionProBadge+Utilities.swift | 47 - .../MentionUtilities+DisplayName.swift | 25 - .../UIContextualAction+Utilities.swift | 208 +- .../_036_GroupsRebuildChanges.swift | 1 - .../Database/Models/BlindedIdLookup.swift | 15 +- .../Database/Models/Capability.swift | 2 +- .../Database/Models/ClosedGroup.swift | 73 +- .../Database/Models/GroupMember.swift | 6 +- .../Database/Models/Interaction.swift | 72 +- .../Database/Models/OpenGroup.swift | 26 +- .../Database/Models/Profile.swift | 65 - .../Database/Models/SessionThread.swift | 132 +- .../Jobs/AttachmentDownloadJob.swift | 2 +- .../Jobs/AttachmentUploadJob.swift | 6 +- .../Jobs/ConfigurationSyncJob.swift | 17 +- .../Jobs/DisplayPictureDownloadJob.swift | 15 +- .../Jobs/ExpirationUpdateJob.swift | 5 +- .../Jobs/GetExpirationJob.swift | 5 +- .../Jobs/GroupInviteMemberJob.swift | 24 +- .../Jobs/GroupLeavingJob.swift | 2 +- .../Jobs/GroupPromoteMemberJob.swift | 13 +- .../Jobs/SendReadReceiptsJob.swift | 12 +- .../LibSession+ConvoInfoVolatile.swift | 6 +- .../LibSession+GroupInfo.swift | 15 +- .../Config Handling/LibSession+Shared.swift | 9 +- .../LibSession+UserGroups.swift | 70 +- .../LibSession+SessionMessagingKit.swift | 21 +- .../LibSession/Types/GroupAuthData.swift | 8 + .../Open Groups/CommunityManager.swift | 68 +- .../Open Groups/Types/Server.swift | 4 +- .../MessageReceiver+Calls.swift | 2 +- .../MessageReceiver+Groups.swift | 24 +- .../MessageReceiver+UnsendRequests.swift | 6 +- .../MessageSender+Groups.swift | 7 +- .../Sending & Receiving/MessageReceiver.swift | 1 + .../NotificationsManagerType.swift | 3 +- ...hNotificationAPI+SessionMessagingKit.swift | 103 +- .../Pollers/SwarmPoller.swift | 1 - .../SessionThreadViewModel.swift | 2344 ----------------- .../Types/ConversationDataCache.swift | 402 +++ .../Types/ConversationDataHelper.swift | 1014 +++++++ .../Types/ConversationInfoViewModel.swift | 899 +++++++ SessionMessagingKit/Types/GlobalSearch.swift | 755 ++++++ .../MessageViewModel+DeletionActions.swift | 456 ++-- .../MessageViewModel.swift | 650 +++-- .../{Shared Models => Types}/Position.swift | 0 .../Authentication+SessionMessagingKit.swift | 60 +- ...ionSelectionView+SessionMessagingKit.swift | 2 +- .../ObservableKey+SessionMessagingKit.swift | 16 +- .../Utilities/Profile+Updating.swift | 6 +- .../_TestUtilities/MockLibSessionCache.swift | 8 + .../SOGS/Crypto/Authentication+SOGS.swift | 22 +- .../SimplifiedConversationCell.swift | 16 +- SessionShareExtension/ThreadPickerVC.swift | 9 +- .../ThreadPickerViewModel.swift | 94 +- .../SwiftUI/QuoteView_SwiftUI.swift | 34 +- SessionUIKit/Style Guide/ThemeManager.swift | 258 +- .../Themes/ThemedAttributedString.swift | 27 +- .../Style Guide/Themes/UIKit+Theme.swift | 127 +- SessionUIKit/Types/Localization.swift | 51 +- .../Utilities/Localization+Style.swift | 116 +- SessionUIKit/Utilities/MentionUtilities.swift | 206 +- .../Utilities/Notifications+Utilities.swift | 38 + .../Combine/Publisher+Utilities.swift | 12 + .../Database/Types/PagedData.swift | 20 + .../General/Authentication.swift | 37 +- .../Observations/ObservationUtilities.swift | 117 +- 103 files changed, 7033 insertions(+), 6128 deletions(-) delete mode 100644 Session/Utilities/MentionUtilities+DisplayName.swift create mode 100644 SessionMessagingKit/LibSession/Types/GroupAuthData.swift delete mode 100644 SessionMessagingKit/Shared Models/SessionThreadViewModel.swift create mode 100644 SessionMessagingKit/Types/ConversationDataCache.swift create mode 100644 SessionMessagingKit/Types/ConversationDataHelper.swift create mode 100644 SessionMessagingKit/Types/ConversationInfoViewModel.swift create mode 100644 SessionMessagingKit/Types/GlobalSearch.swift rename SessionMessagingKit/{Shared Models => Types}/MessageViewModel+DeletionActions.swift (59%) rename SessionMessagingKit/{Shared Models => Types}/MessageViewModel.swift (59%) rename SessionMessagingKit/{Shared Models => Types}/Position.swift (100%) create mode 100644 SessionUIKit/Utilities/Notifications+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 61eaa3035d..0a2ade308e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -624,6 +624,10 @@ FD2CFB8E2EDD00F500EC7F98 /* SessionProOriginatingAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */; }; FD2CFB932EDD0B4300EC7F98 /* BuildVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */; }; FD2CFB972EDE645D00EC7F98 /* SessionPro+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */; }; + FD2CFB992EDFF32E00EC7F98 /* ConversationDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */; }; + FD2CFB9B2EE0FECE00EC7F98 /* ConversationDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */; }; + FD2CFB9D2EE3F63600EC7F98 /* GroupAuthData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */; }; + FD2CFB9F2EE6293B00EC7F98 /* GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */; }; FD2DD5902C6DD13C0073D9BE /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD2DD58F2C6DD13C0073D9BE /* DifferenceKit */; }; FD306BCC2EB02D9E00ADB003 /* GetProDetailsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */; }; FD306BCE2EB02E3600ADB003 /* Signature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD306BCD2EB02E3400ADB003 /* Signature.swift */; }; @@ -722,7 +726,7 @@ FD39370C2E4D7BCA00571F17 /* DocumentPickerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; - FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; + FD3E0C84283B5835002A425C /* ConversationInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */; }; FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */; }; FD3F2EF22DF273D900FD6849 /* ThemedAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */; }; FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */; }; @@ -1006,7 +1010,6 @@ FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */; }; FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */; }; FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; - FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */; }; FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD62DC9A61600564172 /* NotificationCategory.swift */; }; FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */; }; FD99A39F2EBAA5EA00E59F94 /* DecodedEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */; }; @@ -1142,6 +1145,8 @@ FDD23AF02E459EDD0057E853 /* _020_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _020_AddJobUniqueHash.swift */; }; FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; + FDD42F462EE7B12100771A4C /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FDD42F452EE7B12100771A4C /* Lucide */; }; + FDD42F482EE8D8ED00771A4C /* Notifications+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */; }; FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; @@ -2112,6 +2117,10 @@ FD2CFB8D2EDD00EE00EC7F98 /* SessionProOriginatingAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProOriginatingAccount.swift; sourceTree = ""; }; FD2CFB922EDD0B3F00EC7F98 /* BuildVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildVariant.swift; sourceTree = ""; }; FD2CFB962EDE645900EC7F98 /* SessionPro+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionPro+Convenience.swift"; sourceTree = ""; }; + FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDataCache.swift; sourceTree = ""; }; + FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDataHelper.swift; sourceTree = ""; }; + FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupAuthData.swift; sourceTree = ""; }; + FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearch.swift; sourceTree = ""; }; FD306BCB2EB02D9B00ADB003 /* GetProDetailsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsRequest.swift; sourceTree = ""; }; FD306BCD2EB02E3400ADB003 /* Signature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signature.swift; sourceTree = ""; }; FD306BCF2EB02F3500ADB003 /* GetProDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetProDetailsResponse.swift; sourceTree = ""; }; @@ -2185,7 +2194,7 @@ FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentPickerHandler.swift; sourceTree = ""; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; - FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; + FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationInfoViewModel.swift; sourceTree = ""; }; FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerSpec.swift; sourceTree = ""; }; FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedAttributedString.swift; sourceTree = ""; }; FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Updating.swift"; sourceTree = ""; }; @@ -2380,7 +2389,6 @@ FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelperSpec.swift; sourceTree = ""; }; FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicationSpec.swift; sourceTree = ""; }; FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExtensionHelper.swift; sourceTree = ""; }; - FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+DisplayName.swift"; sourceTree = ""; }; FD981BD62DC9A61600564172 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUserInfoKey.swift; sourceTree = ""; }; FD99A39E2EBAA5E500E59F94 /* DecodedEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedEnvelope.swift; sourceTree = ""; }; @@ -2510,6 +2518,7 @@ FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = ""; }; FDD383702AFDD0E1001367F2 /* BencodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponse.swift; sourceTree = ""; }; FDD383722AFDD6D7001367F2 /* BencodeResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponseSpec.swift; sourceTree = ""; }; + FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notifications+Utilities.swift"; sourceTree = ""; }; FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResult.swift; sourceTree = ""; }; FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _033_ScheduleAppUpdateCheckJob.swift; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; @@ -2765,6 +2774,7 @@ FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, + FDD42F462EE7B12100771A4C /* Lucide in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionNetworkingKit.framework in Frameworks */, FD6A39132C2A946A00762359 /* SwiftProtobuf in Frameworks */, ); @@ -2967,7 +2977,6 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, - FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, 45B5360D206DD8BB00D61655 /* UIResponder+OWS.swift */, @@ -3649,6 +3658,7 @@ FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, + FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */, FD8A5B242DC05B16004C689B /* Number+Utilities.swift */, FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, @@ -4090,7 +4100,6 @@ C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* LibSession */, FDAA36C32EB4740E0040603E /* SessionPro */, - FD3E0C82283B581F002A425C /* Shared Models */, FDAA36BA2EB3FC8C0040603E /* Types */, C3BBE0B32554F0D30050F1E3 /* Utilities */, FD245C612850664300B966DD /* Configuration.swift */, @@ -4671,17 +4680,6 @@ path = Contacts; sourceTree = ""; }; - FD3E0C82283B581F002A425C /* Shared Models */ = { - isa = PBXGroup; - children = ( - FD71162B28E1451400B47552 /* Position.swift */, - FD848B86283B844B000E298B /* MessageViewModel.swift */, - FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */, - FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */, - ); - path = "Shared Models"; - sourceTree = ""; - }; FD3F2EE52DE6CC3500FD6849 /* Notifications */ = { isa = PBXGroup; children = ( @@ -5335,8 +5333,15 @@ FDAA36BA2EB3FC8C0040603E /* Types */ = { isa = PBXGroup; children = ( - FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */, + FD2CFB982EDFF2FD00EC7F98 /* ConversationDataCache.swift */, + FD2CFB9A2EE0FECA00EC7F98 /* ConversationDataHelper.swift */, + FD3E0C83283B5835002A425C /* ConversationInfoViewModel.swift */, FD1F3CF22ED657A800E536D5 /* Constants+LibSession.swift */, + FD2CFB9E2EE6293700EC7F98 /* GlobalSearch.swift */, + FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */, + FD848B86283B844B000E298B /* MessageViewModel.swift */, + FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */, + FD71162B28E1451400B47552 /* Position.swift */, ); path = Types; sourceTree = ""; @@ -5412,6 +5417,7 @@ isa = PBXGroup; children = ( FDC1BD652CFD6C4E002CDC71 /* Config.swift */, + FD2CFB9C2EE3F63400EC7F98 /* GroupAuthData.swift */, FD78E9F52DDD43AB00D55B50 /* Mutation.swift */, FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */, FDB11A4F2DCC6ADD00BEF49F /* ThreadUpdateInfo.swift */, @@ -5969,6 +5975,7 @@ FD6673F92D7021F800041530 /* SessionUtil */, FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */, FD360EA82ECAB0DE0050CAF4 /* SDWebImage */, + FDD42F452EE7B12100771A4C /* Lucide */, ); productName = SessionMessagingKit; productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; @@ -6782,6 +6789,7 @@ 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, + FDD42F482EE8D8ED00771A4C /* Notifications+Utilities.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, 94519A952E851BF500F02723 /* SessionProPaymentScreen+UpdatePlan.swift in Sources */, @@ -7275,6 +7283,7 @@ FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */, + FD2CFB9F2EE6293B00EC7F98 /* GlobalSearch.swift in Sources */, FD2272FC2C352D8E004D8A6C /* LibSession+Contacts.swift in Sources */, FD99A3B22EC3E2F500E59F94 /* OWSAudioPlayer.swift in Sources */, FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, @@ -7299,6 +7308,7 @@ FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, FDE754F22C9BB08B002A2623 /* Crypto+SessionMessagingKit.swift in Sources */, + FD2CFB9B2EE0FECE00EC7F98 /* ConversationDataHelper.swift in Sources */, FD4C53AF2CC1D62E003B10F4 /* _035_ReworkRecipientState.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD6C67242CF6E72E00B350A7 /* NoopSessionCallManager.swift in Sources */, @@ -7308,7 +7318,7 @@ FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, FD78EA0D2DDFEDE200D55B50 /* LibSession+Local.swift in Sources */, FDD23AE12E457CDE0057E853 /* _005_SNK_SetupStandardJobs.swift in Sources */, - FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */, + FD3E0C84283B5835002A425C /* ConversationInfoViewModel.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FDE754F02C9BB08B002A2623 /* Crypto+Attachments.swift in Sources */, FD17D79927F40AB800122BE0 /* _009_SMK_YDBToGRDBMigration.swift in Sources */, @@ -7344,6 +7354,7 @@ FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, FD22726B2C32911C004D8A6C /* SendReadReceiptsJob.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, + FD2CFB992EDFF32E00EC7F98 /* ConversationDataCache.swift in Sources */, FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, @@ -7401,6 +7412,7 @@ FDBA8A842D597975007C19C0 /* FailedGroupInvitesAndPromotionsJob.swift in Sources */, FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */, FD360ED82ED3E5C20050CAF4 /* SessionProPlan.swift in Sources */, + FD2CFB9D2EE3F63600EC7F98 /* GroupAuthData.swift in Sources */, FD1A55432E179AED003761E4 /* ObservableKeyEvent+Utilities.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDD23AE92E458E020057E853 /* _003_SUK_YDBToGRDBMigration.swift in Sources */, @@ -7642,7 +7654,6 @@ C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, - FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, FD4B200E283492210034334B /* AfterLayoutCallbackTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, @@ -11649,6 +11660,11 @@ package = FD6A39202C2AA91D00762359 /* XCRemoteSwiftPackageReference "NVActivityIndicatorView" */; productName = NVActivityIndicatorView; }; + FDD42F452EE7B12100771A4C /* Lucide */ = { + isa = XCSwiftPackageProductDependency; + package = FD756BEE2D06686500BD7199 /* XCRemoteSwiftPackageReference "session-lucide" */; + productName = Lucide; + }; FDEF57292C3CF50B00131302 /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = FD6A390E2C2A93CD00762359 /* XCRemoteSwiftPackageReference "WebRTC" */; diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 233deaaa72..445a51611e 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -250,7 +250,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { message: message, threadId: thread.id, interactionId: interaction?.id, - authMethod: try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies) + authMethod: try Authentication.with(swarmPublicKey: thread.id, using: dependencies) ) .retry(5) // Start the timeout timer for the call diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index d579d8a1a5..a9fe94c978 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -181,14 +181,11 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { } dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in - ( - try Authentication.with(db, swarmPublicKey: thread.id, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) - ) + .writePublisher { db -> DisappearingMessagesConfiguration? in + try DisappearingMessagesConfiguration.fetchOne(db, id: thread.id) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { authMethod, disappearingMessagesConfiguration in + .tryFlatMap { disappearingMessagesConfiguration in try MessageSender.preparedSend( message: CallMessage( uuid: uuid, @@ -201,7 +198,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: thread.id, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ).send(using: dependencies) @@ -226,7 +226,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { let mediaConstraints: RTCMediaConstraints = mediaConstraints(false) return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -235,12 +235,9 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } - .flatMap { [weak self, dependencies] authMethod, disappearingMessagesConfiguration in + .flatMap { [weak self, dependencies] disappearingMessagesConfiguration in Future { resolver in self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in if let error = error { @@ -271,7 +268,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -313,7 +313,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { self.queuedICECandidates.removeAll() return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -322,13 +322,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: contactSessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: contactSessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies] disappearingMessagesConfiguration in Log.info(.calls, "Batch sending \(candidates.count) ICE candidates.") return try MessageSender @@ -346,7 +343,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: contactSessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -368,7 +368,7 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { public func endCall(with sessionId: String) { return dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> (AuthenticationMethod, DisappearingMessagesConfiguration?) in + .readPublisher { db -> DisappearingMessagesConfiguration? in /// Ensure a thread exists for the `sessionId` and that it's a `contact` thread guard SessionThread @@ -377,13 +377,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { .isNotEmpty(db) else { throw WebRTCSessionError.noThread } - return ( - try Authentication.with(db, swarmPublicKey: sessionId, using: dependencies), - try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) - ) + return try DisappearingMessagesConfiguration.fetchOne(db, id: sessionId) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .tryFlatMap { [dependencies, uuid] authMethod, disappearingMessagesConfiguration in + .tryFlatMap { [dependencies, uuid] disappearingMessagesConfiguration in Log.info(.calls, "Sending end call message.") return try MessageSender @@ -398,7 +395,10 @@ public final class WebRTCSession: NSObject, RTCPeerConnectionDelegate { namespace: .default, interactionId: nil, attachments: nil, - authMethod: authMethod, + authMethod: try Authentication.with( + swarmPublicKey: sessionId, + using: dependencies + ), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 2b1ebcc94a..5b39acf6d8 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -187,8 +187,10 @@ extension ContextMenuVC { static func actions( for cellViewModel: MessageViewModel, - in threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, reactionsSupported: Bool, + recentReactionEmoji: [String], isUserModeratorOrAdmin: Bool, forMessageInfoScreen: Bool, delegate: ContextMenuActionDelegate?, @@ -260,12 +262,13 @@ extension ContextMenuVC { cellViewModel.threadVariant != .community && !forMessageInfoScreen ) - let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( + let canDelete: Bool = ((try? MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], - threadData: threadViewModel, + threadInfo: threadInfo, + authMethod: authMethod, isUserModeratorOrAdmin: isUserModeratorOrAdmin, using: dependencies - ) != nil) + )) != nil) let canBan: Bool = ( cellViewModel.threadVariant == .community && isUserModeratorOrAdmin @@ -274,8 +277,7 @@ extension ContextMenuVC { let recentEmojis: [EmojiWithSkinTones] = { guard reactionsSupported else { return [] } - return (threadViewModel.recentReactionEmoji ?? []) - .compactMap { EmojiWithSkinTones(rawValue: $0) } + return recentReactionEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) } }() let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 69e80a7b90..a4b759c7c1 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -90,7 +90,7 @@ extension ConversationSearchController: UISearchResultsUpdating { .readPublisher { db -> [Interaction.TimestampInfo] in try Interaction.idsForTermWithin( threadId: threadId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + pattern: try GlobalSearch.pattern(db, searchTerm: searchText) ) .fetchAll(db) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 090dcad0db..6392b6a560 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -29,7 +29,7 @@ extension ConversationVC: @MainActor @objc func handleTitleViewTapped() { // Don't take the user to settings for unapproved threads - guard viewModel.state.threadViewModel.threadRequiresApproval == false else { return } + guard !viewModel.state.threadInfo.requiresApproval else { return } openSettingsFromTitleView() } @@ -47,10 +47,10 @@ extension ConversationVC: @MainActor func openSettingsFromTitleView() { // If we shouldn't be able to access settings then disable the title view shortcuts - guard viewModel.state.threadViewModel.canAccessSettings(using: viewModel.dependencies) else { return } + guard viewModel.state.threadInfo.canAccessSettings else { return } - switch (titleView.currentLabelType, viewModel.state.threadVariant, viewModel.state.threadViewModel.currentUserIsClosedGroupMember, viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin) { - case (.userCount, .group, _, true), (.userCount, .legacyGroup, _, true): + switch (titleView.currentLabelType, viewModel.state.threadVariant, viewModel.state.threadInfo.groupInfo?.currentUserRole) { + case (.userCount, .group, .admin), (.userCount, .legacyGroup, .admin): let viewController = SessionTableViewController( viewModel: EditGroupViewModel( threadId: self.viewModel.state.threadId, @@ -59,11 +59,11 @@ extension ConversationVC: ) navigationController?.pushViewController(viewController, animated: true) - case (.userCount, .group, true, _), (.userCount, .legacyGroup, true, _): + case (.userCount, .group, .some), (.userCount, .legacyGroup, .some): let viewController: UIViewController = ThreadSettingsViewModel.createMemberListViewController( threadId: self.viewModel.state.threadId, - transitionToConversation: { [weak self, dependencies = viewModel.dependencies] maybeThreadViewModel in - guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + transitionToConversation: { [weak self, dependencies = viewModel.dependencies] maybeThreadInfo in + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { self?.navigationController?.present( ConfirmationModal( info: ConfirmationModal.Info( @@ -81,7 +81,7 @@ extension ConversationVC: self?.navigationController?.pushViewController( ConversationVC( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: dependencies ), @@ -92,8 +92,8 @@ extension ConversationVC: ) navigationController?.pushViewController(viewController, animated: true) - case (.disappearingMessageSetting, _, _, _): - guard let config: DisappearingMessagesConfiguration = self.viewModel.state.threadViewModel.disappearingMessagesConfiguration else { + case (.disappearingMessageSetting, _, _): + guard let config: DisappearingMessagesConfiguration = self.viewModel.state.threadInfo.disappearingMessagesConfiguration else { return openSettings() } @@ -101,22 +101,21 @@ extension ConversationVC: viewModel: ThreadDisappearingMessagesSettingsViewModel( threadId: self.viewModel.state.threadId, threadVariant: self.viewModel.state.threadVariant, - currentUserIsClosedGroupMember: self.viewModel.state.threadViewModel.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin, + currentUserRole: self.viewModel.state.threadInfo.groupInfo?.currentUserRole, config: config, using: self.viewModel.dependencies ) ) navigationController?.pushViewController(viewController, animated: true) - case (.userCount, _, _, _), (.none, _, _, _), (.notificationSettings, _, _, _): openSettings() + case (.userCount, _, _), (.none, _, _), (.notificationSettings, _, _): openSettings() } } @objc func openSettings() { - let viewController = SessionTableViewController(viewModel: ThreadSettingsViewModel( - threadId: self.viewModel.state.threadId, - threadVariant: self.viewModel.state.threadVariant, + let viewController = SessionTableViewController( + viewModel: ThreadSettingsViewModel( + threadInfo: self.viewModel.state.threadInfo, didTriggerSearch: { [weak self] in DispatchQueue.main.async { self?.hasPendingInputKeyboardPresentationEvent = true @@ -147,7 +146,7 @@ extension ConversationVC: // MARK: - Call @objc func startCall(_ sender: Any?) { - guard viewModel.state.threadViewModel.threadIsBlocked != true else { + guard !viewModel.state.threadInfo.isBlocked else { self.showBlockedModalIfNeeded() return } @@ -221,7 +220,7 @@ extension ConversationVC: let call: SessionCall = SessionCall( for: self.viewModel.state.threadId, - contactName: self.viewModel.state.threadViewModel.displayName, + contactName: self.viewModel.state.threadInfo.displayName.deformatted(), uuid: UUID().uuidString.lowercased(), mode: .offer, using: viewModel.dependencies @@ -235,18 +234,18 @@ extension ConversationVC: @MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool { guard self.viewModel.state.threadVariant == .contact && - self.viewModel.state.threadViewModel.threadIsBlocked == true + self.viewModel.state.threadInfo.isBlocked else { return false } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: String( format: "blockUnblock".localized(), - self.viewModel.state.threadViewModel.displayName + self.viewModel.state.threadInfo.displayName.deformatted() ), body: .attributedText( "blockUnblockName" - .put(key: "name", value: viewModel.state.threadViewModel.displayName) + .put(key: "name", value: viewModel.state.threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) ), confirmTitle: "blockUnblock".localized(), @@ -552,7 +551,7 @@ extension ConversationVC: attachments: attachments, messageText: snInputView.text, quoteViewModel: snInputView.quoteViewModel, - disableLinkPreviewImageDownload: (self.viewModel.state.threadViewModel.threadCanUpload != true), + disableLinkPreviewImageDownload: !self.viewModel.state.threadInfo.canUpload, didLoadLinkPreview: nil, onQuoteCancelled: { [weak self] in self?.snInputView.quoteViewModel = nil @@ -569,7 +568,7 @@ extension ConversationVC: // MARK: - InputViewDelegate @MainActor func handleDisabledInputTapped() { - guard viewModel.state.threadViewModel.threadIsBlocked == true else { return } + guard viewModel.state.threadInfo.isBlocked else { return } self.showBlockedModalIfNeeded() } @@ -660,7 +659,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.state.threadViewModel.threadIsMessageRequest == true else { return } + guard viewModel.state.threadInfo.isMessageRequest else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastAttachments".localized(), @@ -677,7 +676,7 @@ extension ConversationVC: /// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were /// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order /// to prevent this happening in the future we've added this toast when pressing on the disabled button - guard viewModel.state.threadViewModel.threadIsMessageRequest == true else { return } + guard viewModel.state.threadInfo.isMessageRequest else { return } let toastController: ToastController = ToastController( text: "messageRequestDisabledToastVoiceMessages".localized(), @@ -764,7 +763,7 @@ extension ConversationVC: // If we have no content then do nothing guard !processedText.isEmpty || !attachments.isEmpty else { return } - if processedText.contains(mnemonic) && !viewModel.state.threadViewModel.threadIsNoteToSelf && !hasPermissionToSendSeed { + if processedText.contains(mnemonic) && !viewModel.state.threadInfo.isNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -813,8 +812,8 @@ extension ConversationVC: await approveMessageRequestIfNeeded( for: self.viewModel.state.threadId, threadVariant: self.viewModel.state.threadVariant, - displayName: self.viewModel.state.threadViewModel.displayName, - isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), + displayName: self.viewModel.state.threadInfo.displayName.deformatted(), + isDraft: self.viewModel.state.threadInfo.isDraft, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) @@ -835,10 +834,11 @@ extension ConversationVC: do { try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if state.threadViewModel.threadShouldBeVisible == false { + if !state.threadInfo.shouldBeVisible { try SessionThread.updateVisibility( db, threadId: state.threadId, + threadVariant: state.threadVariant, isVisible: true, additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], using: dependencies @@ -1115,7 +1115,7 @@ extension ConversationVC: func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the unblock modal if needed - guard self.viewModel.state.threadViewModel.threadIsBlocked != true else { + guard !self.viewModel.state.threadInfo.isBlocked else { self.showBlockedModalIfNeeded() return } @@ -1134,8 +1134,10 @@ extension ConversationVC: contextMenuWindow == nil, let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.state.threadViewModel, + threadInfo: self.viewModel.state.threadInfo, + authMethod: self.viewModel.state.authMethod.value, reactionsSupported: self.viewModel.state.reactionsSupported, + recentReactionEmoji: self.viewModel.state.recentReactionEmoji, isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: false, delegate: self, @@ -1266,6 +1268,7 @@ extension ConversationVC: /// Notify of update db.addConversationEvent( id: cellViewModel.threadId, + variant: cellViewModel.threadVariant, type: .updated(.disappearingMessageConfiguration(messageDisappearingConfig)) ) } @@ -1565,11 +1568,11 @@ extension ConversationVC: cancelTitle: "urlCopy".localized(), cancelStyle: .alert_text, hasCloseButton: true, - onConfirm: { [weak self] modal in + onConfirm: { modal in UIApplication.shared.open(url, options: [:], completionHandler: nil) modal.dismiss(animated: true) }, - onCancel: { [weak self] modal in + onCancel: { modal in UIPasteboard.general.string = url.absoluteString } ) @@ -1583,41 +1586,50 @@ extension ConversationVC: } func showUserProfileModal(for cellViewModel: MessageViewModel) { - guard - viewModel.state.threadViewModel.threadCanWrite == true, - let info: UserProfileModal.Info = cellViewModel.createUserProfileModalInfo( - onStartThread: { - Task.detached(priority: .userInitiated) { [weak self] in - await self?.startThread( - with: cellViewModel.authorId, - openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, - openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey - ) - } - }, - onProBadgeTapped: { [weak self, dependencies = viewModel.dependencies] in - dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .generic, - dismissType: .single, - afterClosed: { [weak self] in - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - }, - presenting: { modal in - dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + guard viewModel.state.threadInfo.canWrite else { return } + + Task.detached(priority: .userInitiated) { [weak self, dependencies = viewModel.dependencies] in + guard + let info: UserProfileModal.Info = await cellViewModel.createUserProfileModalInfo( + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey, + onStartThread: { + Task.detached(priority: .userInitiated) { [weak self] in + await self?.startThread( + with: cellViewModel.authorId, + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey + ) } + }, + onProBadgeTapped: { [weak self, dependencies] in + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .generic, + dismissType: .single, + afterClosed: { [weak self] in + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { modal in + dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + } + ) + }, + using: dependencies + ) + else { return } + + await MainActor.run { [weak self] in + guard let self else { return } + + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: info, + dataManager: viewModel.dependencies[singleton: .imageDataManager] ) - }, - using: viewModel.dependencies - ) - else { return } - - let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModal( - info: info, - dataManager: viewModel.dependencies[singleton: .imageDataManager] - ) - ) - present(userProfileModal, animated: true, completion: nil) + ) + present(userProfileModal, animated: true, completion: nil) + } + } } func startThread( @@ -1625,10 +1637,9 @@ extension ConversationVC: openGroupServer: String?, openGroupPublicKey: String? ) async { - guard viewModel.state.threadViewModel.threadCanWrite == true else { return } + guard viewModel.state.threadInfo.canWrite else { return } - let userSessionId: SessionId = viewModel.state.userSessionId - let maybeThreadViewModel: SessionThreadViewModel? = try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + let maybeThreadInfo: ConversationInfoViewModel? = try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in let targetId: String switch try? SessionId.Prefix(from: sessionId) { @@ -1668,19 +1679,15 @@ extension ConversationVC: using: dependencies ) - return try ConversationViewModel.fetchThreadViewModel( + return try ConversationViewModel.fetchConversationInfo( db, threadId: sessionId, - userSessionId: userSessionId, - currentUserSessionIds: [userSessionId.hexString], - threadWasKickedFromGroup: false, - threadGroupIsDestroyed: false, using: dependencies ) } await MainActor.run { [dependencies = viewModel.dependencies] in - guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { self.navigationController?.present( ConfirmationModal( info: ConfirmationModal.Info( @@ -1698,7 +1705,7 @@ extension ConversationVC: self.navigationController?.pushViewController( ConversationVC( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: dependencies ), @@ -1826,10 +1833,7 @@ extension ConversationVC: func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) async { guard cellViewModel.threadVariant == .community, - let roomToken: String = viewModel.state.threadViewModel.openGroupRoomToken, - let server: String = viewModel.state.threadViewModel.openGroupServer, - let publicKey: String = viewModel.state.threadViewModel.openGroupPublicKey, - let capabilities: Set = viewModel.state.threadViewModel.openGroupCapabilities, + let communityInfo: ConversationInfoViewModel.CommunityInfo = viewModel.state.threadInfo.communityInfo, let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId else { return } @@ -1838,20 +1842,20 @@ extension ConversationVC: .addPendingReaction( emoji: emoji, id: openGroupServerMessageId, - in: roomToken, - on: server, + in: communityInfo.roomToken, + on: communityInfo.server, type: .removeAll ) let request = try Network.SOGS.preparedReactionDeleteAll( emoji: emoji, id: openGroupServerMessageId, - roomToken: roomToken, - authMethod: Authentication.community( + roomToken: communityInfo.roomToken, + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities + roomToken: communityInfo.roomToken, + server: communityInfo.server, + publicKey: communityInfo.publicKey, + capabilities: communityInfo.capabilities ) ), using: viewModel.dependencies @@ -1898,7 +1902,7 @@ extension ConversationVC: func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) async { guard self.viewModel.state.reactionsSupported && - self.viewModel.state.threadViewModel.threadIsMessageRequest != true && ( + !self.viewModel.state.threadInfo.isMessageRequest && ( cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing ) @@ -1907,12 +1911,11 @@ extension ConversationVC: // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) let threadId: String = self.viewModel.state.threadId let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant - let openGroupRoom: String? = self.viewModel.state.threadViewModel.openGroupRoomToken - let openGroupServer: String? = self.viewModel.state.threadViewModel.openGroupServer - let openGroupPublicKey: String? = self.viewModel.state.threadViewModel.openGroupPublicKey + let communityInfo: ConversationInfoViewModel.CommunityInfo? = self.viewModel.state.threadInfo.communityInfo + let authMethod: AuthenticationMethod = self.viewModel.state.authMethod.value let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let recentReactionTimestamps: [Int64] = viewModel.dependencies[cache: .general].recentReactionTimestamps - let currentUserSessionIds: Set = viewModel.state.currentUserSessionIds + let currentUserSessionIds: Set = viewModel.state.threadInfo.currentUserSessionIds guard recentReactionTimestamps.count < 20 || @@ -1945,30 +1948,30 @@ extension ConversationVC: do { // Create the pending change if we have open group info - let threadShouldBeVisible: Bool? = self.viewModel.state.threadViewModel.threadShouldBeVisible + let threadShouldBeVisible: Bool? = self.viewModel.state.threadInfo.shouldBeVisible if threadVariant == .community { guard let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupServer: String = openGroupServer, - let openGroupPublicKey: String = openGroupPublicKey + let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo else { throw MessageError.invalidMessage("Missing community info for adding reaction") } pendingChange = await viewModel.dependencies[singleton: .communityManager].addPendingReaction( emoji: emoji, id: serverMessageId, - in: openGroupServer, - on: openGroupPublicKey, + in: communityInfo.server, + on: communityInfo.publicKey, type: (remove ? .remove : .add) ) } - let (destination, authMethod): (Message.Destination, AuthenticationMethod) = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in + let destination: Message.Destination = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) if threadShouldBeVisible == false { try SessionThread.updateVisibility( db, threadId: cellViewModel.threadId, + threadVariant: cellViewModel.threadVariant, isVisible: true, using: dependencies ) @@ -2036,18 +2039,18 @@ extension ConversationVC: Emoji.addRecent(db, emoji: emoji) } - return ( - try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), - try Authentication.with(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) - ) + return try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant) } switch threadVariant { case .community: guard let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, - let openGroupRoom: String = openGroupRoom + let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo else { throw MessageError.invalidMessage("Missing community info for adding reaction") } + guard !authMethod.isInvalid else { + throw MessageError.invalidMessage("Invalid auth method for adding reaction") + } let request: Network.PreparedRequest @@ -2056,7 +2059,7 @@ extension ConversationVC: .preparedReactionDelete( emoji: emoji, id: serverMessageId, - roomToken: openGroupRoom, + roomToken: communityInfo.roomToken, authMethod: authMethod, using: viewModel.dependencies ) @@ -2067,7 +2070,7 @@ extension ConversationVC: .preparedReactionAdd( emoji: emoji, id: serverMessageId, - roomToken: openGroupRoom, + roomToken: communityInfo.roomToken, authMethod: authMethod, using: viewModel.dependencies ) @@ -2201,6 +2204,7 @@ extension ConversationVC: roomToken: room, server: server, publicKey: publicKey, + joinedAt: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), forceVisible: false ) } @@ -2257,31 +2261,31 @@ extension ConversationVC: func info(_ cellViewModel: MessageViewModel) { let actions: [ContextMenuVC.Action] = ContextMenuVC.actions( for: cellViewModel, - in: self.viewModel.state.threadViewModel, + threadInfo: self.viewModel.state.threadInfo, + authMethod: self.viewModel.state.authMethod.value, reactionsSupported: self.viewModel.state.reactionsSupported, + recentReactionEmoji: self.viewModel.state.recentReactionEmoji, isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin, forMessageInfoScreen: true, delegate: self, using: viewModel.dependencies ) ?? [] - // FIXME: This is an interim solution until the `ConversationViewModel` queries are refactored to use the new observation system let messageInfoViewController = MessageInfoViewController( actions: actions, messageViewModel: cellViewModel, - threadCanWrite: (viewModel.state.threadViewModel.threadCanWrite == true), + threadCanWrite: viewModel.state.threadInfo.canWrite, + openGroupServer: viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: viewModel.state.threadInfo.communityInfo?.publicKey, onStartThread: { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in await self?.startThread( with: cellViewModel.authorId, - openGroupServer: self?.viewModel.state.threadViewModel.openGroupServer, - openGroupPublicKey: self?.viewModel.state.threadViewModel.openGroupPublicKey + openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server, + openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey ) } }, - displayNameRetriever: { [weak self] sessionId, inMessageBody in - self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) - }, using: viewModel.dependencies ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in @@ -2353,7 +2357,7 @@ extension ConversationVC: cellViewModel.variant == .standardIncoming else { return } guard - (cellViewModel.body ?? "")?.isEmpty == false || + (cellViewModel.bubbleBody ?? "")?.isEmpty == false || !cellViewModel.attachments.isEmpty else { return } @@ -2386,12 +2390,14 @@ extension ConversationVC: case .typingIndicator, .dateHeader, .unreadMarker, .infoMessage, .call: break case .textOnlyMessage: - if cellViewModel.body == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview { + if cellViewModel.bodyForCopying == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview { UIPasteboard.general.string = linkPreview.url return } - - UIPasteboard.general.string = cellViewModel.body + else if let value: String = cellViewModel.bodyForCopying { + /// Don't override the pasteboard with a null value + UIPasteboard.general.string = value + } case .audio, .voiceMessage, .genericAttachment, .mediaMessage: guard @@ -2441,8 +2447,17 @@ extension ConversationVC: func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { /// Retrieve the deletion actions for the selected message(s) of there are any let messagesToDelete: [MessageViewModel] = [cellViewModel] + let deletionBehaviours: MessageViewModel.DeletionBehaviours - guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else { + do { + guard let behaviours: MessageViewModel.DeletionBehaviours = try self.viewModel.deletionActions(for: messagesToDelete) else { + return + } + + deletionBehaviours = behaviours + } + catch { + Log.error(.conversation, "Failed to retrieve deletion actions due to error: \(error)") return } @@ -2611,7 +2626,7 @@ extension ConversationVC: isSavingMedia: true, presentingViewController: self, using: viewModel.dependencies - ) { [weak self, dependencies = viewModel.dependencies] in + ) { [weak self, threadVariant = viewModel.state.threadVariant, dependencies = viewModel.dependencies] in PHPhotoLibrary.shared().performChanges( { validAttachments.forEach { attachment, path in @@ -2646,7 +2661,7 @@ extension ConversationVC: } // Send a 'media saved' notification if needed - guard self?.viewModel.state.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + guard threadVariant == .contact, cellViewModel.variant == .standardIncoming else { return } @@ -2672,31 +2687,19 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.state.threadViewModel, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, - let roomToken: String = threadData.openGroupRoomToken, - let server: String = threadData.openGroupServer, - let publicKey: String = threadData.openGroupPublicKey, - let capabilities: Set = threadData.openGroupCapabilities, + let roomToken: String = threadInfo.communityInfo?.roomToken, + !authMethod.isInvalid, cellViewModel.openGroupServerMessageId != nil else { throw CryptoError.invalidAuthentication } - return ( - roomToken, - Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities - ) - ) - ) + return roomToken } .publisher - .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + .tryFlatMap { roomToken in try Network.SOGS.preparedUserBan( sessionId: cellViewModel.authorId, from: [roomToken], @@ -2728,7 +2731,7 @@ extension ConversationVC: } ) }, - afterClosed: { [weak self] in + afterClosed: { completion?() } ) @@ -2747,30 +2750,18 @@ extension ConversationVC: confirmTitle: "theContinue".localized(), confirmStyle: .danger, cancelStyle: .alert_text, - onConfirm: { [weak self, threadData = viewModel.state.threadViewModel, dependencies = viewModel.dependencies] _ in + onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in Result { guard cellViewModel.threadVariant == .community, - let roomToken: String = threadData.openGroupRoomToken, - let server: String = threadData.openGroupServer, - let publicKey: String = threadData.openGroupPublicKey, - let capabilities: Set = threadData.openGroupCapabilities + let roomToken: String = threadInfo.communityInfo?.roomToken, + !authMethod.isInvalid else { throw CryptoError.invalidAuthentication } - return ( - roomToken, - Authentication.community( - info: LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: capabilities - ) - ) - ) + return roomToken } .publisher - .tryFlatMap { (roomToken: String, authMethod: AuthenticationMethod) in + .tryFlatMap { roomToken in try Network.SOGS.preparedUserBanAndDeleteAllMessages( sessionId: cellViewModel.authorId, roomToken: roomToken, @@ -2860,15 +2851,16 @@ extension ConversationVC: // Limit voice messages to a minute audioTimer = Timer.scheduledTimer(withTimeInterval: 180, repeats: false, block: { [weak self] _ in - self?.snInputView.hideVoiceMessageUI() - self?.endVoiceMessageRecording() + DispatchQueue.main.async { + self?.snInputView.hideVoiceMessageUI() + self?.endVoiceMessageRecording() + } }) // Prepare audio recorder and start recording let successfullyPrepared: Bool = audioRecorder.prepareToRecord() let startedRecording: Bool = (successfullyPrepared && audioRecorder.record()) - guard successfullyPrepared && startedRecording else { Log.error(.conversation, (successfullyPrepared ? "Couldn't record audio." : "Couldn't prepare audio recorder.")) @@ -3179,8 +3171,8 @@ extension ConversationVC { await approveMessageRequestIfNeeded( for: self.viewModel.state.threadId, threadVariant: self.viewModel.state.threadVariant, - displayName: self.viewModel.state.threadViewModel.displayName, - isDraft: (self.viewModel.state.threadViewModel.threadIsDraft == true), + displayName: self.viewModel.state.threadInfo.displayName.deformatted(), + isDraft: self.viewModel.state.threadInfo.isDraft, timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) } @@ -3192,7 +3184,7 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.state.threadViewModel, + threadInfo: self.viewModel.state.threadInfo, viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies @@ -3215,7 +3207,7 @@ extension ConversationVC { for: .trailing, indexPath: IndexPath(row: 0, section: 0), tableView: self.tableView, - threadViewModel: self.viewModel.state.threadViewModel, + threadInfo: self.viewModel.state.threadInfo, viewController: self, navigatableStateHolder: nil, using: viewModel.dependencies @@ -3238,7 +3230,7 @@ extension ConversationVC { extension ConversationVC { @objc public func recreateLegacyGroupTapped() { let threadId: String = self.viewModel.state.threadId - let closedGroupName: String? = self.viewModel.state.threadViewModel.closedGroupName + let closedGroupName: String? = self.viewModel.state.threadInfo.groupInfo?.name let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "recreateGroup".localized(), diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index b5a5cbaad4..48d72ea806 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -186,7 +186,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa info: InfoBanner.Info( font: .systemFont(ofSize: Values.verySmallFontSize), message: "disappearingMessagesLegacy" - .put(key: "name", value: self.viewModel.state.threadViewModel.displayName) + .put(key: "name", value: self.viewModel.state.threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)), icon: .close, tintColor: .messageBubble_outgoingText, @@ -234,7 +234,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ) result.isHidden = ( viewModel.state.threadVariant != .group || - viewModel.state.threadViewModel.closedGroupExpired != true + viewModel.state.threadInfo.groupInfo?.expired != true ) return result @@ -304,10 +304,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView( threadVariant: self.viewModel.state.threadVariant, - canWrite: (self.viewModel.state.threadViewModel.threadCanWrite == true), - threadIsMessageRequest: (self.viewModel.state.threadViewModel.threadIsMessageRequest == true), - threadRequiresApproval: (self.viewModel.state.threadViewModel.threadRequiresApproval == true), - closedGroupAdminProfile: self.viewModel.state.threadViewModel.closedGroupAdminProfile, + canWrite: self.viewModel.state.threadInfo.canWrite, + threadIsMessageRequest: self.viewModel.state.threadInfo.isMessageRequest, + threadRequiresApproval: self.viewModel.state.threadInfo.requiresApproval, + closedGroupAdminProfile: self.viewModel.state.threadInfo.groupInfo?.adminProfile, onBlock: { [weak self] in self?.blockMessageRequest() }, onAccept: { [weak self] in self?.acceptMessageRequest() }, onDecline: { [weak self] in self?.declineMessageRequest() } @@ -317,7 +317,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let result: UIView = UIView() result.isHidden = ( viewModel.state.threadVariant != .legacyGroup || - viewModel.state.threadViewModel.currentUserIsClosedGroupAdmin != true + viewModel.state.threadInfo.groupInfo?.currentUserRole != .admin ) result.addSubview(legacyGroupsFooterButton) @@ -476,12 +476,12 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - Initialization init( - threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, using dependencies: Dependencies ) { self.viewModel = ConversationViewModel( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: focusedInteractionInfo, currentUserMentionImage: MentionUtilities.generateCurrentUserMentionImage( textColor: MessageViewModel.bodyTextColor(isOutgoing: false) /// Outgoing messages don't use the image @@ -507,12 +507,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // nav will be offset incorrectly during the push animation (unfortunately the profile icon still // doesn't appear until after the animation, I assume it's taking a snapshot or something, but // there isn't much we can do about that unfortunately) - updateNavBarButtons( - threadData: nil, - initialVariant: self.viewModel.state.threadVariant, - initialIsNoteToSelf: self.viewModel.state.threadViewModel.threadIsNoteToSelf, - initialIsBlocked: (self.viewModel.state.threadViewModel.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: self.viewModel.state.threadInfo) titleView.update(with: self.viewModel.state.titleViewModel) // Constraints @@ -665,8 +660,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.navigationController == nil || self.navigationController?.viewControllers.contains(self) == false ) && - viewModel.state.threadViewModel.threadIsNoteToSelf == false && - viewModel.state.threadViewModel.threadIsDraft == true + !viewModel.state.threadInfo.isNoteToSelf && + viewModel.state.threadInfo.isDraft { viewModel.dependencies[singleton: .storage].writeAsync { db in _ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave` @@ -701,47 +696,41 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Update general conversation UI titleView.update(with: state.titleViewModel) - updateNavBarButtons( - threadData: state.threadViewModel, - initialVariant: state.threadVariant, - initialIsNoteToSelf: state.threadViewModel.threadIsNoteToSelf, - initialIsBlocked: (state.threadViewModel.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: state.threadInfo) addOrRemoveOutdatedClientBanner( - outdatedMemberId: state.threadViewModel.outdatedMemberId, - disappearingMessagesConfiguration: state.threadViewModel.disappearingMessagesConfiguration + contactInfo: state.threadInfo.contactInfo, + disappearingMessagesConfiguration: state.threadInfo.disappearingMessagesConfiguration ) legacyGroupsBanner.isHidden = (state.threadVariant != .legacyGroup) expiredGroupBanner.isHidden = ( state.threadVariant != .group || - state.threadViewModel.closedGroupExpired != true + state.threadInfo.groupInfo?.expired != true ) - updateUnreadCountView(unreadCount: state.threadViewModel.threadUnreadCount) + updateUnreadCountView(unreadCount: state.threadInfo.unreadCount) snInputView.setMessageInputState(state.messageInputState) messageRequestFooterView.update( threadVariant: state.threadVariant, - canWrite: (state.threadViewModel.threadCanWrite == true), - threadIsMessageRequest: (state.threadViewModel.threadIsMessageRequest == true), - threadRequiresApproval: (state.threadViewModel.threadRequiresApproval == true), - closedGroupAdminProfile: state.threadViewModel.closedGroupAdminProfile + canWrite: state.threadInfo.canWrite, + threadIsMessageRequest: state.threadInfo.isMessageRequest, + threadRequiresApproval: state.threadInfo.requiresApproval, + closedGroupAdminProfile: state.threadInfo.groupInfo?.adminProfile ) // Only set the draft content on the initial load (once we have data) - if !initialLoadComplete, let draft: String = state.threadViewModel.threadMessageDraft, !draft.isEmpty { + if !initialLoadComplete, !state.threadInfo.messageDraft.isEmpty { let (string, _) = MentionUtilities.getMentions( - in: draft, - currentUserSessionIds: state.currentUserSessionIds, + in: state.threadInfo.messageDraft, + currentUserSessionIds: state.threadInfo.currentUserSessionIds, displayNameRetriever: { [weak self] sessionId, inMessageBody in - // TODO: [PRO] Replicate this behaviour everywhere self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) } ) snInputView.text = string - snInputView.updateNumberOfCharactersLeft(draft) + snInputView.updateNumberOfCharactersLeft(state.threadInfo.messageDraft) } // Update the table content @@ -1161,12 +1150,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } - func updateNavBarButtons( - threadData: SessionThreadViewModel?, - initialVariant: SessionThread.Variant, - initialIsNoteToSelf: Bool, - initialIsBlocked: Bool - ) { + func updateNavBarButtons(threadInfo: ConversationInfoViewModel) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -1175,14 +1159,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } else { let shouldHaveCallButton: Bool = ( - (threadData?.threadVariant ?? initialVariant) == .contact && - (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false + threadInfo.variant == .contact && + !threadInfo.isNoteToSelf ) - guard - let threadData: SessionThreadViewModel = threadData, - threadData.canAccessSettings(using: viewModel.dependencies) - else { + guard threadInfo.canAccessSettings else { // Note: Adding empty buttons because without it the title alignment is busted (Note: The size was // taken from the layout inspector for the back button in Xcode navigationItem.rightBarButtonItems = [ @@ -1211,11 +1192,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa dataManager: viewModel.dependencies[singleton: .imageDataManager] ) profilePictureView.update( - publicKey: threadData.threadId, // Contact thread uses the contactId - threadVariant: threadData.threadVariant, - displayPictureUrl: threadData.threadDisplayPictureUrl, - profile: threadData.profile, - additionalProfile: threadData.additionalProfile, + publicKey: threadInfo.id, // Contact thread uses the contactId + threadVariant: threadInfo.variant, + displayPictureUrl: threadInfo.displayPictureUrl, + profile: threadInfo.profile, + additionalProfile: threadInfo.additionalProfile, using: viewModel.dependencies ) profilePictureView.customWidth = (44 - 16) // Width of the standard back button @@ -1248,10 +1229,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // MARK: - General func addOrRemoveOutdatedClientBanner( - outdatedMemberId: String?, + contactInfo: ConversationInfoViewModel.ContactInfo?, disappearingMessagesConfiguration: DisappearingMessagesConfiguration? ) { - let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.state.threadViewModel.disappearingMessagesConfiguration + let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.state.threadInfo.disappearingMessagesConfiguration // Do not show the banner until the new disappearing messages is enabled guard currentDisappearingMessagesConfiguration?.isEnabled == true else { self.outdatedClientBanner.isHidden = true @@ -1262,7 +1243,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa return } - guard let outdatedMemberId: String = outdatedMemberId else { + guard + let contactInfo: ConversationInfoViewModel.ContactInfo = contactInfo, + !contactInfo.isCurrentUser, + contactInfo.lastKnownClientVersion == FeatureVersion.legacyDisappearingMessages + else { UIView.animate( withDuration: 0.25, animations: { [weak self] in @@ -1283,7 +1268,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa self.outdatedClientBanner.update( message: "disappearingMessagesLegacy" - .put(key: "name", value: (viewModel.displayName(for: outdatedMemberId, inMessageBody: true) ?? outdatedMemberId.truncated())) + .put(key: "name", value: contactInfo.displayNameInMessageBody) .localizedFormatted(baseFont: self.outdatedClientBanner.font), onTap: { [weak self] in self?.removeOutdatedClientBanner() } ) @@ -1296,11 +1281,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } private func removeOutdatedClientBanner() { - guard let outdatedMemberId: String = self.viewModel.state.threadViewModel.outdatedMemberId else { return } + guard let contactInfo: ConversationInfoViewModel.ContactInfo = self.viewModel.state.threadInfo.contactInfo else { return } viewModel.dependencies[singleton: .storage].writeAsync { db in try Contact - .filter(id: outdatedMemberId) + .filter(id: contactInfo.id) .updateAll(db, Contact.Columns.lastKnownClientVersion.set(to: nil)) } } @@ -1358,9 +1343,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa .contains(cellViewModel.id), lastSearchText: viewModel.lastSearchedText, tableSize: tableView.bounds.size, - displayNameRetriever: { [weak self] sessionId, inMessageBody in - self?.viewModel.displayName(for: sessionId, inMessageBody: inMessageBody) - }, using: viewModel.dependencies ) cell.delegate = self @@ -1439,8 +1421,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let messages: [MessageViewModel] = self.sections[messagesSectionIndex].elements let lastInteractionInfo: Interaction.TimestampInfo = { guard - let interactionId: Int64 = self.viewModel.state.threadViewModel.interactionId, - let timestampMs: Int64 = self.viewModel.state.threadViewModel.interactionTimestampMs + let interactionId: Int64 = self.viewModel.state.threadInfo.lastInteraction?.id, + let timestampMs: Int64 = self.viewModel.state.threadInfo.lastInteraction?.timestampMs else { return Interaction.TimestampInfo( id: messages[messages.count - 1].id, @@ -1507,12 +1489,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } } - func updateUnreadCountView(unreadCount: UInt?) { - let unreadCount: Int = Int(unreadCount ?? 0) + func updateUnreadCountView(unreadCount: Int) { let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") // stringlint:ignore unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) - unreadCountView.isHidden = (unreadCount == 0) + unreadCountView.isHidden = (unreadCount <= 0) } public func updateScrollToBottom(force: Bool = false) { @@ -1595,12 +1576,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } // Nav bar buttons - updateNavBarButtons( - threadData: viewModel.state.threadViewModel, - initialVariant: viewModel.state.threadVariant, - initialIsNoteToSelf: viewModel.state.threadViewModel.threadIsNoteToSelf, - initialIsBlocked: (viewModel.state.threadViewModel.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: viewModel.state.threadInfo) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1635,12 +1611,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons( - threadData: viewModel.state.threadViewModel, - initialVariant: viewModel.state.threadVariant, - initialIsNoteToSelf: viewModel.state.threadViewModel.threadIsNoteToSelf, - initialIsBlocked: (viewModel.state.threadViewModel.threadIsBlocked == true) - ) + updateNavBarButtons(threadInfo: viewModel.state.threadInfo) searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil UIView.animate(withDuration: 0.3) { diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 7638142eac..d6b98e61b4 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -89,14 +89,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Initialization @MainActor init( - threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, currentUserMentionImage: UIImage, using dependencies: Dependencies ) { self.dependencies = dependencies self.state = State.initialState( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: focusedInteractionInfo, currentUserMentionImage: currentUserMentionImage, using: dependencies @@ -136,10 +136,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } let viewState: ViewState - let threadId: String - let threadVariant: SessionThread.Variant - let userSessionId: SessionId - let currentUserSessionIds: Set + let threadInfo: ConversationInfoViewModel + let authMethod: EquatableAuthenticationMethod let currentUserMentionImage: UIImage let isBlindedContact: Bool let wasPreviouslyBlindedContact: Bool @@ -150,56 +148,52 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let initialUnreadInteractionInfo: Interaction.TimestampInfo? let loadedPageInfo: PagedData.LoadedInfo - let profileCache: [String: Profile] - var linkPreviewCache: [String: [LinkPreview]] - let interactionCache: [Int64: Interaction] - let attachmentCache: [String: Attachment] - let reactionCache: [Int64: [Reaction]] - let quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] - let attachmentMap: [Int64: Set] - let unblindedIdMap: [String: String] - let modAdminCache: Set - let itemCache: [Int64: MessageViewModel] + let dataCache: ConversationDataCache + let itemCache: [MessageViewModel.ID: MessageViewModel] let titleViewModel: ConversationTitleViewModel - let threadViewModel: SessionThreadViewModel - let threadContact: Contact? - let threadIsTrusted: Bool let legacyGroupsBannerIsVisible: Bool let reactionsSupported: Bool + let recentReactionEmoji: [String] let isUserModeratorOrAdmin: Bool let shouldShowTypingIndicator: Bool let optimisticallyInsertedMessages: [Int64: OptimisticMessageData] + // Convenience + + var threadId: String { threadInfo.id } + var threadVariant: SessionThread.Variant { threadInfo.variant } + var userSessionId: SessionId { threadInfo.userSessionId } + var emptyStateText: String { - let blocksCommunityMessageRequests: Bool = (threadViewModel.profile?.blocksCommunityMessageRequests == true) + let blocksCommunityMessageRequests: Bool = (threadInfo.profile?.blocksCommunityMessageRequests == true) - switch (threadViewModel.threadIsNoteToSelf, threadViewModel.threadCanWrite == true, blocksCommunityMessageRequests, threadViewModel.wasKickedFromGroup, threadViewModel.groupIsDestroyed) { + switch (threadInfo.isNoteToSelf, threadInfo.canWrite, blocksCommunityMessageRequests, threadInfo.groupInfo?.wasKicked, threadInfo.groupInfo?.isDestroyed) { case (true, _, _, _, _): return "noteToSelfEmpty".localized() case (_, false, true, _, _): return "messageRequestsTurnedOff" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadInfo.displayName.deformatted()) .localized() case (_, _, _, _, true): return "groupDeletedMemberDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadInfo.displayName.deformatted()) .localized() case (_, _, _, true, _): return "groupRemovedYou" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadInfo.displayName.deformatted()) .localized() case (_, false, false, _, _): return "conversationsEmpty" - .put(key: "conversation_name", value: threadViewModel.displayName) + .put(key: "conversation_name", value: threadInfo.displayName.deformatted()) .localized() default: return "groupNoMessages" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadInfo.displayName.deformatted()) .localized() } } @@ -207,7 +201,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold var legacyGroupsBannerMessage: ThemedAttributedString { let localizationKey: String - switch threadViewModel.currentUserIsClosedGroupAdmin == true { + switch threadInfo.groupInfo?.currentUserRole == .admin { case false: localizationKey = "legacyGroupAfterDeprecationMember" case true: localizationKey = "legacyGroupAfterDeprecationAdmin" } @@ -225,8 +219,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } public var messageInputState: InputView.InputState { - guard !threadViewModel.threadIsNoteToSelf else { return InputView.InputState(inputs: .all) } - guard threadViewModel.threadIsBlocked != true else { + guard !threadInfo.isNoteToSelf else { return InputView.InputState(inputs: .all) } + guard !threadInfo.isBlocked else { return InputView.InputState( inputs: .disabled, message: "blockBlockedDescription".localized(), @@ -236,7 +230,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - if threadViewModel.threadVariant == .community && threadViewModel.threadCanWrite == false { + if threadInfo.variant == .community && !threadInfo.canWrite { return InputView.InputState( inputs: .disabled, message: "permissionsWriteCommunity".localized() @@ -246,7 +240,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// Attachments shouldn't be allowed for message requests or if uploads are disabled let finalInputs: InputView.Input - switch (threadViewModel.threadRequiresApproval, threadViewModel.threadIsMessageRequest, threadViewModel.threadCanUpload) { + switch (threadInfo.requiresApproval, threadInfo.isMessageRequest, threadInfo.canUpload) { case (false, false, true): finalInputs = .all default: finalInputs = [.text, .attachmentsDisabled, .voiceMessagesDisabled] } @@ -262,76 +256,53 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold public var observedKeys: Set { var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), .loadPage(ConversationViewModel.self), .updateScreen(ConversationViewModel.self), - .conversationUpdated(threadId), - .conversationDeleted(threadId), - .profile(userSessionId.hexString), - .typingIndicator(threadId), - .messageCreated(threadId: threadId) + .conversationUpdated(threadInfo.id), + .conversationDeleted(threadInfo.id), + .profile(threadInfo.userSessionId.hexString), + .typingIndicator(threadInfo.id), + .messageCreated(threadId: threadInfo.id), + .recentReactionsUpdated ] - /// Add thread-variant specific events (eg. ensure the display picture and title change when profiles are updated, initial - /// data is loaded, etc.) - switch threadViewModel.threadVariant { - case .contact: - result.insert(.profile(threadViewModel.threadId)) - result.insert(.contact(threadViewModel.threadId)) - - case .group: - if let frontProfileId: String = threadViewModel.closedGroupProfileFront?.id { - result.insert(.profile(frontProfileId)) - } - - if let backProfileId: String = threadViewModel.closedGroupProfileBack?.id { - result.insert(.profile(backProfileId)) - } - - case .community: - result.insert(.communityUpdated(threadId)) - result.insert(.anyContactUnblinded) - - default: break - } - - /// Observe changes to messages - interactionCache.keys.forEach { messageId in - result.insert(.messageUpdated(id: messageId, threadId: threadId)) - result.insert(.messageDeleted(id: messageId, threadId: threadId)) - result.insert(.reactionsChanged(messageId: messageId)) - result.insert(.attachmentCreated(messageId: messageId)) - - attachmentMap[messageId]?.forEach { interactionAttachment in - result.insert(.attachmentUpdated(id: interactionAttachment.attachmentId, messageId: messageId)) - result.insert(.attachmentDeleted(id: interactionAttachment.attachmentId, messageId: messageId)) - } + if SessionId.Prefix.isCommunityBlinded(threadInfo.id) { + result.insert(.anyContactUnblinded) } - /// Observe changes to profile data - profileCache.forEach { profileId, _ in - result.insert(.profile(profileId)) - } + result.insert(contentsOf: threadInfo.observedKeys) + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) return result } static func initialState( - threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo?, currentUserMentionImage: UIImage, using dependencies: Dependencies ) -> State { - let userSessionId: SessionId = dependencies[cache: .general].sessionId + let dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .messageList(threadId: threadInfo.id), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) return State( viewState: .loading, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - userSessionId: userSessionId, - currentUserSessionIds: [userSessionId.hexString], + threadInfo: threadInfo, + authMethod: EquatableAuthenticationMethod(value: Authentication.invalid), currentUserMentionImage: currentUserMentionImage, - isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), - wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadViewModel.threadId), + isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id), + wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(threadInfo.id), focusedInteractionInfo: focusedInteractionInfo, focusBehaviour: (focusedInteractionInfo == nil ? .none : .highlight), initialUnreadInteractionInfo: nil, @@ -339,33 +310,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold record: Interaction.self, pageSize: ConversationViewModel.pageSize, requiredJoinSQL: nil, - filterSQL: MessageViewModel.interactionFilterSQL(threadId: threadViewModel.threadId), + filterSQL: MessageViewModel.interactionFilterSQL(threadId: threadInfo.id), groupSQL: nil, orderSQL: MessageViewModel.interactionOrderSQL ), - profileCache: [:], - linkPreviewCache: [:], - interactionCache: [:], - attachmentCache: [:], - reactionCache: [:], - quoteMap: [:], - attachmentMap: [:], - unblindedIdMap: [:], - modAdminCache: [], + dataCache: dataCache, itemCache: [:], titleViewModel: ConversationTitleViewModel( - threadViewModel: threadViewModel, - profileCache: [:], + threadInfo: threadInfo, + dataCache: dataCache, using: dependencies ), - threadViewModel: threadViewModel, - threadContact: nil, - threadIsTrusted: false, - legacyGroupsBannerIsVisible: (threadViewModel.threadVariant == .legacyGroup), + legacyGroupsBannerIsVisible: (threadInfo.variant == .legacyGroup), reactionsSupported: ( - threadViewModel.threadVariant != .legacyGroup && - threadViewModel.threadIsMessageRequest != true + threadInfo.variant != .legacyGroup && + threadInfo.isMessageRequest != true ), + recentReactionEmoji: [], isUserModeratorOrAdmin: false, shouldShowTypingIndicator: false, optimisticallyInsertedMessages: [:] @@ -375,7 +336,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold fileprivate static func orderedIdsIncludingOptimisticMessages( loadedPageInfo: PagedData.LoadedInfo, optimisticMessages: [Int64: OptimisticMessageData], - interactionCache: [Int64: Interaction] + dataCache: ConversationDataCache ) -> [Int64] { guard !optimisticMessages.isEmpty else { return loadedPageInfo.currentIds } @@ -388,7 +349,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold var result: [Int64] = [] while !remainingPagedIds.isEmpty || !remainingSortedOptimisticMessages.isEmpty { - let nextPaged: Interaction? = remainingPagedIds.first.map { interactionCache[$0] } + let nextPaged: Interaction? = remainingPagedIds.first.map { dataCache.interaction(for: $0) } let nextOptimistic: OptimisticMessageData? = remainingSortedOptimisticMessages.first?.1 switch (nextPaged, nextOptimistic) { @@ -421,7 +382,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold at index: Int, orderedIds: [Int64], optimisticMessages: [Int64: OptimisticMessageData], - interactionCache: [Int64: Interaction] + dataCache: ConversationDataCache ) -> Interaction? { guard index >= 0, index < orderedIds.count else { return nil } guard orderedIds[index] >= 0 else { @@ -429,7 +390,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return optimisticMessages[orderedIds[index]]?.interaction } - return interactionCache[orderedIds[index]] + return dataCache.interaction(for: orderedIds[index]) } } @@ -439,36 +400,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold isInitialQuery: Bool, using dependencies: Dependencies ) async -> State { - var threadId: String = previousState.threadId - let threadVariant: SessionThread.Variant = previousState.threadVariant - var currentUserSessionIds: Set = previousState.currentUserSessionIds + var threadId: String = previousState.threadInfo.id + var threadInfo: ConversationInfoViewModel = previousState.threadInfo + var authMethod: EquatableAuthenticationMethod = previousState.authMethod var focusedInteractionInfo: Interaction.TimestampInfo? = previousState.focusedInteractionInfo var initialUnreadInteractionInfo: Interaction.TimestampInfo? = previousState.initialUnreadInteractionInfo var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult - var profileCache: [String: Profile] = previousState.profileCache - var linkPreviewCache: [String: [LinkPreview]] = previousState.linkPreviewCache - var interactionCache: [Int64: Interaction] = previousState.interactionCache - var attachmentCache: [String: Attachment] = previousState.attachmentCache - var reactionCache: [Int64: [Reaction]] = previousState.reactionCache - var quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = previousState.quoteMap - var attachmentMap: [Int64: Set] = previousState.attachmentMap - var unblindedIdMap: [String: String] = previousState.unblindedIdMap - var modAdminCache: Set = previousState.modAdminCache - var itemCache: [Int64: MessageViewModel] = previousState.itemCache - var threadViewModel: SessionThreadViewModel = previousState.threadViewModel - var threadContact: Contact? = previousState.threadContact - var threadIsTrusted: Bool = previousState.threadIsTrusted + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [MessageViewModel.ID: MessageViewModel] = previousState.itemCache var reactionsSupported: Bool = previousState.reactionsSupported + var recentReactionEmoji: [String] = previousState.recentReactionEmoji var isUserModeratorOrAdmin: Bool = previousState.isUserModeratorOrAdmin - var threadWasKickedFromGroup: Bool = (threadViewModel.wasKickedFromGroup == true) - var threadGroupIsDestroyed: Bool = (threadViewModel.groupIsDestroyed == true) var shouldShowTypingIndicator: Bool = false var optimisticallyInsertedMessages: [Int64: OptimisticMessageData] = previousState.optimisticallyInsertedMessages /// Store a local copy of the events so we can manipulate it based on the state changes var eventsToProcess: [ObservedEvent] = events - var profileIdsNeedingFetch: Set = [] var shouldFetchInitialUnreadInteractionInfo: Bool = false + var shouldFetchInitialRecentReactions: Bool = false /// If this is the initial query then we need to properly fetch the initial state if isInitialQuery { @@ -482,45 +431,33 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold )) /// Determine reactions support - switch threadVariant { + switch threadInfo.variant { case .legacyGroup: reactionsSupported = false - isUserModeratorOrAdmin = (threadViewModel.currentUserIsClosedGroupAdmin == true) + isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin) case .contact: - reactionsSupported = (threadViewModel.threadIsMessageRequest != true) + reactionsSupported = !threadInfo.isMessageRequest shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators] - .isRecipientTyping(threadId: threadId) + .isRecipientTyping(threadId: threadInfo.id) case .group: - reactionsSupported = (threadViewModel.threadIsMessageRequest != true) - isUserModeratorOrAdmin = (threadViewModel.currentUserIsClosedGroupAdmin == true) + reactionsSupported = !threadInfo.isMessageRequest + isUserModeratorOrAdmin = (threadInfo.groupInfo?.currentUserRole == .admin) case .community: reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport( capability: .reactions, - on: threadViewModel.openGroupServer - ) - - /// Get the session id options for the current user - if - let server: String = threadViewModel.openGroupServer, - let serverInfo: CommunityManager.Server = await dependencies[singleton: .communityManager].server(server) - { - currentUserSessionIds = serverInfo.currentUserSessionIds - } - - modAdminCache = await dependencies[singleton: .communityManager].allModeratorsAndAdmins( - server: threadViewModel.openGroupServer, - roomToken: threadViewModel.openGroupRoomToken, - includingHidden: true + on: threadInfo.communityInfo?.server ) - isUserModeratorOrAdmin = !modAdminCache.isDisjoint(with: currentUserSessionIds) } /// Determine whether we need to fetch the initial unread interaction info shouldFetchInitialUnreadInteractionInfo = (initialUnreadInteractionInfo == nil) + /// We need to fetch the recent reactions if they are supported + shouldFetchInitialRecentReactions = reactionsSupported + /// Check if the typing indicator should be visible shouldShowTypingIndicator = await dependencies[singleton: .typingIndicators].isRecipientTyping( threadId: threadId @@ -528,84 +465,64 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } /// If there are no events we want to process then just return the current state - guard !eventsToProcess.isEmpty else { return previousState } + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) - var databaseEvents: Set = changes.databaseEvents - var loadPageEvent: LoadPageEvent? = changes.latest(.loadPage, as: LoadPageEvent.self) - - // FIXME: We should be able to make this far more efficient by splitting this query up and only fetching diffs - var threadNeedsRefresh: Bool = ( - threadId != previousState.threadId || - events.contains(where: { - $0.key.generic == .conversationUpdated || - $0.key.generic == .contact || - $0.key.generic == .profile - }) + var changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + var loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) + + /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then we reload the messages + /// based on the initial paged data query just in case - there isn't a perfect solution to capture the current messages plus any + /// others that may have been added by the merge so do the best we can) + if let event: ContactEvent = changes.latest(.anyContactUnblinded, as: ContactEvent.self) { + switch event.change { + case .unblinded(let blindedId, let unblindedId): + /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then + /// we reload the messages based on the initial paged data query just in case - there isn't a perfect + /// solution to capture the current messages plus any others that may have been added by the + /// merge so do the best we can) + guard blindedId == threadId else { break } + + threadId = unblindedId + loadResult = loadResult.info + .with(filterSQL: MessageViewModel.interactionFilterSQL(threadId: unblindedId)) + .asResult + loadPageEvent = .initial + eventsToProcess = eventsToProcess + .filter { $0.key.generic != .loadPage } + .appending( + ObservedEvent( + key: .loadPage(ConversationViewModel.self), + value: LoadPageEvent.initial + ) + ) + changes = eventsToProcess.split(by: { $0.handlingStrategy }) + + default: break + } + } + + /// Update the context + dataCache.withContext( + source: .messageList(threadId: threadId), + requireFullRefresh: ( + isInitialQuery || + threadInfo.id != threadId || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) + ), + requireAuthMethodFetch: authMethod.value.isInvalid, + requiresInitialUnreadInteractionInfo: shouldFetchInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: ( + shouldFetchInitialRecentReactions || + changes.contains(.recentReactionsUpdated) + ) ) /// Handle thread specific changes first (as this could include a conversation being unblinded) - switch threadVariant { - case .contact: - changes.forEach(.contact, as: ContactEvent.self) { event in - switch event.change { - case .isTrusted(let value): - threadContact = threadContact?.with( - isTrusted: .set(to: value), - currentUserSessionId: previousState.userSessionId - ) - - case .isApproved(let value): - threadContact = threadContact?.with( - isApproved: .set(to: value), - currentUserSessionId: previousState.userSessionId - ) - - case .isBlocked(let value): - threadContact = threadContact?.with( - isBlocked: .set(to: value), - currentUserSessionId: previousState.userSessionId - ) - - case .didApproveMe(let value): - threadContact = threadContact?.with( - didApproveMe: .set(to: value), - currentUserSessionId: previousState.userSessionId - ) - - case .unblinded(let blindedId, let unblindedId): - /// Need to handle a potential "unblinding" event first since it changes the `threadId` (and then - /// we reload the messages based on the initial paged data query just in case - there isn't a perfect - /// solution to capture the current messages plus any others that may have been added by the - /// merge so do the best we can) - guard blindedId == threadId else { return } - - threadId = unblindedId - loadResult = loadResult.info - .with(filterSQL: MessageViewModel.interactionFilterSQL(threadId: unblindedId)) - .asResult - loadPageEvent = .initial - databaseEvents.insert( - ObservedEvent( - key: .loadPage(ConversationViewModel.self), - value: LoadPageEvent.initial - ) - ) - } - } - - case .legacyGroup, .group: - changes.forEach(.groupMemberUpdated, as: GroupMemberEvent.self) { event in - switch event.change { - case .none: break - case .role(let role, _): - guard event.profileId == previousState.userSessionId.hexString else { return } - - isUserModeratorOrAdmin = (role == .admin) - } - } - + switch threadInfo.variant { case .community: /// Handle community changes (users could change to mods which would need all of their interaction data updated) changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in @@ -617,165 +534,61 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold loadPageEvent = .initial - case .role(let moderator, let admin, let hiddenModerator, let hiddenAdmin): - isUserModeratorOrAdmin = (moderator || admin || hiddenModerator || hiddenAdmin) - - case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): - modAdminCache = Set(admins + hiddenAdmins + moderators + hiddenModerators) - isUserModeratorOrAdmin = !modAdminCache.isDisjoint(with: currentUserSessionIds) - - // FIXME: When we break apart the SessionThreadViewModel these should be handled - case .capabilities, .permissions: break + case .role, .moderatorsAndAdmins, .capabilities, .permissions: break } } + + default: break } - /// Profile events - changes.forEach(.profile, as: ProfileEvent.self) { event in - guard var profileData: Profile = profileCache[event.id] else { - /// This profile (somehow) isn't in the cache, so we need to fetch it - profileIdsNeedingFetch.insert(event.id) - return - } - - switch event.change { - case .name(let name): profileData = profileData.with(name: name) - case .nickname(let nickname): profileData = profileData.with(nickname: .set(to: nickname)) - case .displayPictureUrl(let url): profileData = profileData.with(displayPictureUrl: .set(to: url)) - case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): - let finalFeatures: SessionPro.ProfileFeatures = { - guard dependencies[feature: .sessionProEnabled] else { return .none } - - return features - .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) - }() - - profileData = profileData.with( - proFeatures: .set(to: finalFeatures), - proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), - proGenIndexHashHex: .set(to: proGenIndexHashHex) - ) - } - - profileCache[event.id] = profileData - } + /// Then process cache updates + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) - /// General unblinding handling - changes.forEach(.anyContactUnblinded, as: ContactEvent.self) { event in - switch event.change { - case .unblinded(let blindedId, let unblindedId): unblindedIdMap[blindedId] = unblindedId - default: break - } - } + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) - /// Pull data from libSession - if threadNeedsRefresh { - let result: (wasKickedFromGroup: Bool, groupIsDestroyed: Bool) = { - guard threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) - } - }() - threadWasKickedFromGroup = result.wasKickedFromGroup - threadGroupIsDestroyed = result.groupIsDestroyed + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.conversation, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } } - /// Then handle database events - if !dependencies[singleton: .storage].isSuspended, (threadNeedsRefresh || !databaseEvents.isEmpty) { + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { - var fetchedInteractions: [Interaction] = [] - var fetchedProfiles: [Profile] = [] - var fetchedLinkPreviews: [LinkPreview] = [] - var fetchedAttachments: [Attachment] = [] - var fetchedInteractionAttachments: [InteractionAttachment] = [] - var fetchedReactions: [Int64: [Reaction]] = [:] - var fetchedQuoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = [:] - var fetchedBlindedIdLookups: [BlindedIdLookup] = [] - - /// Identify any inserted/deleted records - var insertedInteractionIds: Set = [] - var updatedInteractionIds: Set = [] - var deletedInteractionIds: Set = [] - var updatedAttachmentIds: Set = [] - var interactionIdsNeedingReactionUpdates: Set = [] - - databaseEvents.forEach { event in - switch event.value { - case let messageEvent as MessageEvent: - guard let messageId: Int64 = messageEvent.id else { return } - - switch event.key.generic { - case .messageCreated: insertedInteractionIds.insert(messageId) - case .messageUpdated: updatedInteractionIds.insert(messageId) - case .messageDeleted: deletedInteractionIds.insert(messageId) - default: break - } - - case let conversationEvent as ConversationEvent: - switch conversationEvent.change { - /// Since we cache whether a messages disappearing message config can be followed we - /// need to update the value if the disappearing message config on the conversation changes - case .disappearingMessageConfiguration: - itemCache.forEach { id, item in - guard item.canFollowDisappearingMessagesSetting else { return } - - updatedInteractionIds.insert(id) - } - - default: break - } - - case let attachmentEvent as AttachmentEvent: - switch event.key.generic { - case .attachmentUpdated: updatedAttachmentIds.insert(attachmentEvent.id) - default: break - } - - case let reactionEvent as ReactionEvent: - interactionIdsNeedingReactionUpdates.insert(reactionEvent.messageId) - - case let communityEvent as CommunityEvent: - switch communityEvent.change { - case .receivedInitialMessages: break /// This is custom handled above - case .role: - updatedInteractionIds.insert( - contentsOf: Set(itemCache - .filter { currentUserSessionIds.contains($0.value.authorId) } - .keys) - ) - - case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): - let modAdminIds: Set = Set(admins + hiddenAdmins + moderators + hiddenModerators) - updatedInteractionIds.insert( - contentsOf: Set(itemCache - .filter { - guard modAdminIds.contains($0.value.authorId) else { - return $0.value.isSenderModeratorOrAdmin - } - - return !$0.value.isSenderModeratorOrAdmin - } - .keys) - ) - - case .capabilities, .permissions: break /// Shouldn't affect messages - } - - default: break - } - } - try await dependencies[singleton: .storage].readAsync { db in - var interactionIdsNeedingFetch: [Int64] = Array(updatedInteractionIds) - var attachmentIdsNeedingFetch: [String] = Array(updatedAttachmentIds) + /// Fetch the `authMethod` if needed + if fetchRequirements.requireAuthMethodFetch { + // TODO: [Database Relocation] Should be able to remove the database requirement now we have the CommunityManager + authMethod = EquatableAuthenticationMethod( + value: try Authentication.with( + db, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + using: dependencies + ) + ) + } - /// Separately fetch the `initialUnreadInteractionInfo` if needed - if shouldFetchInitialUnreadInteractionInfo { + /// Fetch the `initialUnreadInteractionInfo` if needed + if fetchRequirements.requiresInitialUnreadInteractionInfo { initialUnreadInteractionInfo = try Interaction .select(.id, .timestampMs) .filter(Interaction.Columns.wasRead == false) @@ -785,217 +598,38 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .fetchOne(db) } - /// If we don't have the `Contact` data and need it then fetch it now - if threadVariant == .contact && threadContact?.id != threadId { - threadContact = try Contact.fetchOne(db, id: threadId) - } - - /// Update loaded page info as needed - if loadPageEvent != nil || !insertedInteractionIds.isEmpty || !deletedInteractionIds.isEmpty { - let target: PagedData.Target - - switch loadPageEvent?.target { - case .initial: - /// If we don't have an initial `focusedInteractionInfo` then we should default to loading - /// data around the `initialUnreadInteractionInfo` and focus on that - let finalLoadPageEvent: LoadPageEvent = ( - initialUnreadInteractionInfo.map { .initialPageAround(id: $0.id) } ?? - .initial - ) - - focusedInteractionInfo = initialUnreadInteractionInfo - target = ( - finalLoadPageEvent.target(with: loadResult) ?? - .newItems(insertedIds: insertedInteractionIds, deletedIds: deletedInteractionIds) - ) - - default: - target = ( - loadPageEvent?.target(with: loadResult) ?? - .newItems(insertedIds: insertedInteractionIds, deletedIds: deletedInteractionIds) - ) - } - - loadResult = try loadResult.load( - db, - target: target - ) - interactionIdsNeedingFetch += loadResult.newIds - } - - /// Get the ids of any quoted interactions - /// - /// **Note:** We may not be able to find the quoted interaction (hence the `Int64?` but would still want to render - /// the message as a quote) - let quoteInteractionIdResults: Set> = try MessageViewModel - .quotedInteractionIds( - for: interactionIdsNeedingFetch, - currentUserSessionIds: currentUserSessionIds - ) - .fetchSet(db) - quoteInteractionIdResults.forEach { pair in - fetchedQuoteMap[pair.first] = MessageViewModel.MaybeUnresolvedQuotedInfo( - foundQuotedInteractionId: pair.second - ) - } - interactionIdsNeedingFetch += Array(fetchedQuoteMap.values.compactMap { $0.foundQuotedInteractionId }) - - /// Fetch any records needed - fetchedInteractions = try Interaction.fetchAll(db, ids: interactionIdsNeedingFetch) - - /// Determine if we need to fetch any profile data - let profileIdsForFetchedInteractions: Set = fetchedInteractions.reduce(into: []) { result, next in - result.insert(next.authorId) - result.insert(contentsOf: MentionUtilities.allPubkeys(in: (next.body ?? ""))) - } - let missingProfileIds: Set = profileIdsForFetchedInteractions - .subtracting(profileCache.keys) - - if !missingProfileIds.isEmpty { - fetchedProfiles = try Profile.fetchAll(db, ids: Array(missingProfileIds)) - } - - fetchedBlindedIdLookups = try BlindedIdLookup - .filter(ids: Set(fetchedProfiles.map { $0.id })) - .filter(BlindedIdLookup.Columns.sessionId != nil) - .fetchAll(db) - - /// Fetch any link previews needed - let linkPreviewLookupInfo: [(url: String, timestamp: Int64)] = fetchedInteractions.compactMap { - guard let url: String = $0.linkPreviewUrl else { return nil } - - return (url, $0.timestampMs) - } - - if !linkPreviewLookupInfo.isEmpty { - let urls: [String] = linkPreviewLookupInfo.map(\.url) - let minTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).min() ?? 0) - let maxTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).max() ?? Int64.max) - let finalMinTimestamp: TimeInterval = (TimeInterval(minTimestampMs / 1000) - LinkPreview.timstampResolution) - let finalMaxTimestamp: TimeInterval = (TimeInterval(maxTimestampMs / 1000) + LinkPreview.timstampResolution) - - fetchedLinkPreviews = try LinkPreview - .filter(urls.contains(LinkPreview.Columns.url)) - .filter(LinkPreview.Columns.timestamp > finalMinTimestamp) - .filter(LinkPreview.Columns.timestamp < finalMaxTimestamp) - .fetchAll(db) - attachmentIdsNeedingFetch += fetchedLinkPreviews.compactMap { $0.attachmentId } - } - - /// Fetch any attachments needed (ensuring we keep the album order) - fetchedInteractionAttachments = try InteractionAttachment - .filter(interactionIdsNeedingFetch.contains(InteractionAttachment.Columns.interactionId)) - .order(InteractionAttachment.Columns.albumIndex) - .fetchAll(db) - attachmentIdsNeedingFetch += fetchedInteractionAttachments.map { $0.attachmentId } - - if !attachmentIdsNeedingFetch.isEmpty { - fetchedAttachments = try Attachment.fetchAll(db, ids: attachmentIdsNeedingFetch) - } - - /// Fetch any reactions (just refetch all of them as handling individual reaction events, especially with "pending" - /// reactions in SOGS, will likely result in bugs) - interactionIdsNeedingReactionUpdates.insert(contentsOf: Set(interactionIdsNeedingFetch)) - fetchedReactions = try Reaction - .filter(interactionIdsNeedingReactionUpdates.contains(Reaction.Columns.interactionId)) - .fetchAll(db) - .grouped(by: \.interactionId) - - /// Fetch any thread data needed - if threadNeedsRefresh { - threadViewModel = try ConversationViewModel.fetchThreadViewModel( - db, - threadId: threadId, - userSessionId: previousState.userSessionId, - currentUserSessionIds: currentUserSessionIds, - threadWasKickedFromGroup: threadWasKickedFromGroup, - threadGroupIsDestroyed: threadGroupIsDestroyed, - using: dependencies - ) - } - } - - threadIsTrusted = { - switch threadVariant { - case .legacyGroup, .community, .group: return true /// Default to `true` for non-contact threads - case .contact: return (threadContact?.isTrusted == true) + if fetchRequirements.requireRecentReactionEmojiUpdate { + recentReactionEmoji = try Emoji.getRecent(db, withDefaultEmoji: true) } - }() - - /// Update the caches with the newly fetched values - quoteMap.merge(fetchedQuoteMap, uniquingKeysWith: { _, new in new }) - fetchedProfiles.forEach { profile in - let finalFeatures: SessionPro.ProfileFeatures = { - guard dependencies[feature: .sessionProEnabled] else { return .none } - - return profile.proFeatures - .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) - }() - - profileCache[profile.id] = profile.with(proFeatures: .set(to: finalFeatures)) - } - fetchedLinkPreviews.forEach { linkPreviewCache[$0.url, default: []].append($0) } - fetchedAttachments.forEach { attachmentCache[$0.id] = $0 } - fetchedReactions.forEach { interactionId, reactions in - guard !reactions.isEmpty else { - reactionCache.removeValue(forKey: interactionId) - return - } - - reactionCache[interactionId, default: []] = reactions - } - fetchedBlindedIdLookups.forEach { unblindedIdMap[$0.blindedId] = $0.sessionId } - let groupedInteractionAttachments: [Int64: Set] = fetchedInteractionAttachments - .grouped(by: \.interactionId) - .mapValues { Set($0) } - fetchedInteractions.forEach { interaction in - guard let id: Int64 = interaction.id else { return } - - interactionCache[id] = interaction + /// If we don't have an initial `focusedInteractionInfo` (as determined by the `loadPageEvent.target` + /// being `initial`) then we should default to loading data around the `initialUnreadInteractionInfo` + /// and focusing on it if - let attachments: Set = groupedInteractionAttachments[id], - !attachments.isEmpty + loadPageEvent?.target == .initial, + let initialUnreadInteractionInfo: Interaction.TimestampInfo = initialUnreadInteractionInfo { - attachmentMap[id] = attachments - } - else { - attachmentMap.removeValue(forKey: id) + loadPageEvent = .initialPageAround(id: initialUnreadInteractionInfo.id) + focusedInteractionInfo = initialUnreadInteractionInfo } - } - - /// Remove any deleted values - deletedInteractionIds.forEach { id in - itemCache.removeValue(forKey: id) - interactionCache.removeValue(forKey: id) - reactionCache.removeValue(forKey: id) - quoteMap.removeValue(forKey: id) - attachmentMap[id]?.forEach { attachmentCache.removeValue(forKey: $0.attachmentId) } - attachmentMap.removeValue(forKey: id) + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies + ) } } catch { - let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") Log.critical(.conversation, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } - else if !databaseEvents.isEmpty { - Log.warn(.conversation, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") - } - - /// If we refreshed the thread data then reaction support may have changed, so update it - if threadNeedsRefresh { - switch threadVariant { - case .legacyGroup: reactionsSupported = false - case .contact, .group: - reactionsSupported = (threadViewModel.threadIsMessageRequest != true) - - case .community: - reactionsSupported = await dependencies[singleton: .communityManager].doesOpenGroupSupport( - capability: .reactions, - on: threadViewModel.openGroupServer - ) - } + else if !changes.databaseEvents.isEmpty { + Log.warn(.conversation, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } /// Update the typing indicator state if needed @@ -1010,25 +644,29 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold optimisticallyInsertedMessages[data.temporaryId] = data if let attachments: [Attachment] = data.attachmentData { - attachments.forEach { attachmentCache[$0.id] = $0 } - attachmentMap[data.temporaryId] = Set(attachments.enumerated().map { index, attachment in - InteractionAttachment( - albumIndex: index, - interactionId: data.temporaryId, - attachmentId: attachment.id - ) - }) + dataCache.insert(attachments: attachments) + dataCache.insert( + attachmentMap: [ + data.temporaryId: Set(attachments.enumerated().map { index, attachment in + InteractionAttachment( + albumIndex: index, + interactionId: data.temporaryId, + attachmentId: attachment.id + ) + }) + ] + ) } if let viewModel: LinkPreviewViewModel = data.linkPreviewViewModel { - linkPreviewCache[viewModel.urlString, default: []].append( + dataCache.insert(linkPreviews: [ LinkPreview( url: viewModel.urlString, title: viewModel.title, attachmentId: nil, /// Can't save to db optimistically using: dependencies ) - ) + ]) } case .failedToStoreMessage(let temporaryId): @@ -1049,17 +687,37 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) case .resolveOptimisticMessage(let temporaryId, let databaseId): - guard interactionCache[databaseId] != nil else { + guard dataCache.interaction(for: databaseId) != nil else { Log.warn(.conversation, "Attempted to resolve an optimistic message but it was missing from the cache") return } optimisticallyInsertedMessages.removeValue(forKey: temporaryId) - attachmentMap.removeValue(forKey: temporaryId) + dataCache.removeAttachmentMap(for: temporaryId) itemCache.removeValue(forKey: temporaryId) } } + /// Update the `threadInfo` with the latest `dataCache` + if let thread: SessionThread = dataCache.thread(for: threadId) { + threadInfo = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + + /// Update the flag indicating whether reactions are supproted + switch threadInfo.variant { + case .legacyGroup: reactionsSupported = false + case .contact, .group: reactionsSupported = !threadInfo.isMessageRequest + case .community: + reactionsSupported = (threadInfo.communityInfo?.capabilities.contains(.reactions) == true) + isUserModeratorOrAdmin = !dataCache.communityModAdminIds(for: threadId).isDisjoint( + with: dataCache.currentUserSessionIds(for: threadId) + ) + } + /// Generating the `MessageViewModel` requires both the "preview" and "next" messages that will appear on /// the screen in order to be generated correctly so we need to iterate over the interactions again - additionally since /// modifying interactions could impact this clustering behaviour (or ever other cached content), and we add messages @@ -1067,7 +725,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( loadedPageInfo: loadResult.info, optimisticMessages: optimisticallyInsertedMessages, - interactionCache: interactionCache + dataCache: dataCache ) orderedIds.enumerated().forEach { index, id in @@ -1089,66 +747,66 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return MessageViewModel.MaybeUnresolvedQuotedInfo( foundQuotedInteractionId: interactionId, - resolvedQuotedInteraction: interactionCache[interactionId] + resolvedQuotedInteraction: dataCache.interaction(for: interactionId) ) } default: - guard let targetInteraction: Interaction = interactionCache[id] else { return } + guard let targetInteraction: Interaction = dataCache.interaction(for: id) else { return } optimisticMessageId = nil interaction = targetInteraction - reactionInfo = reactionCache[id].map { reactions in - reactions.map { reaction in + + let reactions: [Reaction] = dataCache.reactions(for: id) + + if !reactions.isEmpty { + reactionInfo = reactions.map { reaction in /// If the reactor is the current user then use the proper profile from the cache (instead of a random /// blinded one) - let targetId: String = (currentUserSessionIds.contains(reaction.authorId) ? + let targetId: String = (threadInfo.currentUserSessionIds.contains(reaction.authorId) ? previousState.userSessionId.hexString : reaction.authorId ) return MessageViewModel.ReactionInfo( reaction: reaction, - profile: profileCache[targetId] + profile: dataCache.profile(for: targetId) ) } } - maybeUnresolvedQuotedInfo = quoteMap[id].map { info in + else { + reactionInfo = nil + } + + maybeUnresolvedQuotedInfo = dataCache.quoteInfo(for: id).map { info in MessageViewModel.MaybeUnresolvedQuotedInfo( foundQuotedInteractionId: info.foundQuotedInteractionId, - resolvedQuotedInteraction: info.foundQuotedInteractionId.map { interactionCache[$0] } + resolvedQuotedInteraction: info.foundQuotedInteractionId.map { + dataCache.interaction(for: $0) + } ) } } itemCache[id] = MessageViewModel( optimisticMessageId: optimisticMessageId, - threadId: threadId, - threadVariant: threadVariant, - threadIsTrusted: threadIsTrusted, - threadDisappearingConfiguration: threadViewModel.disappearingMessagesConfiguration, interaction: interaction, reactionInfo: reactionInfo, maybeUnresolvedQuotedInfo: maybeUnresolvedQuotedInfo, - profileCache: profileCache, - attachmentCache: attachmentCache, - linkPreviewCache: linkPreviewCache, - attachmentMap: attachmentMap, - unblindedIdMap: unblindedIdMap, - isSenderModeratorOrAdmin: modAdminCache.contains(interaction.authorId), userSessionId: previousState.userSessionId, - currentUserSessionIds: currentUserSessionIds, + threadInfo: threadInfo, + dataCache: dataCache, previousInteraction: State.interaction( at: index + 1, /// Order is inverted so `previousInteraction` is the next element orderedIds: orderedIds, optimisticMessages: optimisticallyInsertedMessages, - interactionCache: interactionCache + dataCache: dataCache ), nextInteraction: State.interaction( at: index - 1, /// Order is inverted so `nextInteraction` is the previous element orderedIds: orderedIds, optimisticMessages: optimisticallyInsertedMessages, - interactionCache: interactionCache + dataCache: dataCache ), isLast: ( /// Order is inverted so we need to check the start of the list @@ -1165,10 +823,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold at: prefixIndex, orderedIds: orderedIds, optimisticMessages: optimisticallyInsertedMessages, - interactionCache: interactionCache + dataCache: dataCache ) } - .first(where: { currentUserSessionIds.contains($0.authorId) })? + .first(where: { threadInfo.currentUserSessionIds.contains($0.authorId) })? .id ), currentUserMentionImage: previousState.currentUserMentionImage, @@ -1178,10 +836,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return State( viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), - threadId: threadId, - threadVariant: threadVariant, - userSessionId: previousState.userSessionId, - currentUserSessionIds: currentUserSessionIds, + threadInfo: threadInfo, + authMethod: authMethod, currentUserMentionImage: previousState.currentUserMentionImage, isBlindedContact: SessionId.Prefix.isCommunityBlinded(threadId), wasPreviouslyBlindedContact: SessionId.Prefix.isCommunityBlinded(previousState.threadId), @@ -1189,26 +845,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold focusBehaviour: previousState.focusBehaviour, initialUnreadInteractionInfo: initialUnreadInteractionInfo, loadedPageInfo: loadResult.info, - profileCache: profileCache, - linkPreviewCache: linkPreviewCache, - interactionCache: interactionCache, - attachmentCache: attachmentCache, - reactionCache: reactionCache, - quoteMap: quoteMap, - attachmentMap: attachmentMap, - unblindedIdMap: unblindedIdMap, - modAdminCache: modAdminCache, + dataCache: dataCache, itemCache: itemCache, titleViewModel: ConversationTitleViewModel( - threadViewModel: threadViewModel, - profileCache: profileCache, + threadInfo: threadInfo, + dataCache: dataCache, using: dependencies ), - threadViewModel: threadViewModel, - threadContact: threadContact, - threadIsTrusted: threadIsTrusted, legacyGroupsBannerIsVisible: previousState.legacyGroupsBannerIsVisible, reactionsSupported: reactionsSupported, + recentReactionEmoji: recentReactionEmoji, isUserModeratorOrAdmin: isUserModeratorOrAdmin, shouldShowTypingIndicator: shouldShowTypingIndicator, optimisticallyInsertedMessages: optimisticallyInsertedMessages @@ -1219,7 +865,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let orderedIds: [Int64] = State.orderedIdsIncludingOptimisticMessages( loadedPageInfo: state.loadedPageInfo, optimisticMessages: state.optimisticallyInsertedMessages, - interactionCache: state.interactionCache + dataCache: state.dataCache ) /// Messages are fetched in decending order (so the message at index `0` is the most recent message), we then render the @@ -1313,17 +959,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let interaction: Interaction = Interaction( threadId: currentState.threadId, threadVariant: currentState.threadVariant, - authorId: currentState.currentUserSessionIds + authorId: currentState.threadInfo.currentUserSessionIds .first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) } .defaulting(to: currentState.userSessionId.hexString), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, hasMention: Interaction.isUserMentioned( - publicKeysToCheck: currentState.currentUserSessionIds, + publicKeysToCheck: currentState.threadInfo.currentUserSessionIds, body: text ), - expiresInSeconds: currentState.threadViewModel.disappearingMessagesConfiguration?.expiresInSeconds(), + expiresInSeconds: currentState.threadInfo.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewViewModel?.urlString, proMessageFeatures: proMessageFeatures, proProfileFeatures: proProfileFeatures, @@ -1387,7 +1033,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Profiles @MainActor public func displayName(for sessionId: String, inMessageBody: Bool) -> String? { - return state.profileCache[sessionId]?.displayName( + return state.dataCache.profile(for: sessionId)?.displayName( includeSessionIdSuffix: (state.threadVariant == .community && inMessageBody) ) } @@ -1399,9 +1045,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold for: query, threadId: state.threadId, threadVariant: state.threadVariant, - currentUserSessionIds: state.currentUserSessionIds, - communityInfo: state.threadViewModel.openGroupServer.map { server in - state.threadViewModel.openGroupRoomToken.map { (server: server, roomToken: $0) } + currentUserSessionIds: state.threadInfo.currentUserSessionIds, + communityInfo: state.threadInfo.communityInfo.map { info in + (server: info.server, roomToken: info.roomToken) }, using: dependencies ) @@ -1421,16 +1067,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold authorId: viewModel.authorId, authorName: viewModel.authorName(), timestampMs: viewModel.timestampMs, - body: viewModel.body, + body: viewModel.bubbleBody, attachmentInfo: targetAttachment?.quoteAttachmentInfo(using: dependencies) ), showProBadge: viewModel.profile.proFeatures.contains(.proBadge), /// Quote pro badge is profile data currentUserSessionIds: viewModel.currentUserSessionIds, - displayNameRetriever: { [profileCache = state.profileCache] sessionId, inMessageBody in - profileCache[sessionId]?.displayName( - includeSessionIdSuffix: (viewModel.threadVariant == .community && inMessageBody) - ) - }, + displayNameRetriever: state.dataCache.displayNameRetriever( + for: viewModel.threadId, + includeSessionIdSuffixWhenInMessageBody: (viewModel.threadVariant == .community) + ), currentUserMentionImage: viewModel.currentUserMentionImage ) } @@ -1465,15 +1110,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold @MainActor public func updateDraft(to draft: String) { /// Kick off an async process to save the `draft` message to the conversation (don't want to block the UI while doing this, /// worst case the `draft` just won't be saved) - Task.detached(priority: .userInitiated) { [threadViewModel = state.threadViewModel, dependencies] in - do { try await threadViewModel.updateDraft(draft, using: dependencies) } + Task.detached(priority: .userInitiated) { [threadInfo = state.threadInfo, dependencies] in + do { try await threadInfo.updateDraft(draft, using: dependencies) } catch { Log.error(.conversation, "Failed to update draft due to error: \(error)") } } } public func markThreadAsRead() async { - let threadViewModel: SessionThreadViewModel = await state.threadViewModel - try? await threadViewModel.markAsRead(target: .thread, using: dependencies) + let threadInfo: ConversationInfoViewModel = await state.threadInfo + try? await threadInfo.markAsRead(target: .thread, using: dependencies) } /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read @@ -1491,8 +1136,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read let needsToMarkAsRead: Bool = await MainActor.run { guard - (state.threadViewModel.threadUnreadCount ?? 0) > 0 || - state.threadViewModel.threadWasMarkedUnread == true + state.threadInfo.unreadCount > 0 || + state.threadInfo.wasMarkedUnread else { return false } /// We want to mark messages as read while we scroll, so grab the "newest" visible message and mark everything older as read @@ -1548,16 +1193,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold catch { return } /// Get the latest values - let (threadViewModel, pendingInfo): (SessionThreadViewModel, Interaction.TimestampInfo?) = await MainActor.run { + let (threadInfo, pendingInfo): (ConversationInfoViewModel, Interaction.TimestampInfo?) = await MainActor.run { ( - state.threadViewModel, + state.threadInfo, pendingMarkAsReadInfo ) } guard let info: Interaction.TimestampInfo = pendingInfo else { return } - try? await threadViewModel.markAsRead( + try? await threadInfo.markAsRead( target: .threadAndInteractions(interactionsBeforeInclusive: info.id), using: dependencies ) @@ -1630,10 +1275,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold messageExpandedInteractionIds.insert(interactionId) } - @MainActor public func deletionActions(for cellViewModels: [MessageViewModel]) -> MessageViewModel.DeletionBehaviours? { - return MessageViewModel.DeletionBehaviours.deletionActions( + @MainActor public func deletionActions(for cellViewModels: [MessageViewModel]) throws -> MessageViewModel.DeletionBehaviours? { + return try MessageViewModel.DeletionBehaviours.deletionActions( for: cellViewModels, - threadData: state.threadViewModel, + threadInfo: state.threadInfo, + authMethod: state.authMethod.value, isUserModeratorOrAdmin: state.isUserModeratorOrAdmin, using: dependencies ) @@ -1869,129 +1515,112 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold // MARK: - Convenience private extension ObservedEvent { - var dataRequirement: EventDataRequirement { - // FIXME: Should be able to optimise this further - switch (key, key.generic) { - case (_, .loadPage): return .databaseQuery - case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery - case (.anyContactBlockedStatusChanged, _): return .databaseQuery - case (.anyContactUnblinded, _): return .bothDatabaseQueryAndOther - case (_, .typingIndicator): return .databaseQuery - case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery - case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery - case (_, .attachmentCreated), (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery - case (_, .reactionsChanged): return .databaseQuery - case (_, .communityUpdated): return .bothDatabaseQueryAndOther - case (_, .contact): return .bothDatabaseQueryAndOther - case (_, .profile): return .bothDatabaseQueryAndOther - default: return .other - } + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let messageStrategy: EventHandlingStrategy? = MessageViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (_, .loadPage): return .databaseQuery + case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery + case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (.anyContactUnblinded, _): return [.databaseQuery, .directCacheUpdate] + case (.recentReactionsUpdated, _): return .databaseQuery + case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery + case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + case (_, .attachmentCreated), (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery + case (_, .reactionsChanged): return .databaseQuery + case (_, .communityUpdated): return [.databaseQuery, .directCacheUpdate] + case (_, .contact): return [.databaseQuery, .directCacheUpdate] + case (_, .profile): return [.databaseQuery, .directCacheUpdate] + case (_, .typingIndicator): return .directCacheUpdate + default: return .directCacheUpdate + } + }() + + return localStrategy + .union(threadInfoStrategy ?? .none) + .union(messageStrategy ?? .none) } } private extension ConversationTitleViewModel { init( - threadViewModel: SessionThreadViewModel, - profileCache: [String: Profile], + threadInfo: ConversationInfoViewModel, + dataCache: ConversationDataCache, using dependencies: Dependencies ) { - self.threadVariant = threadViewModel.threadVariant - self.displayName = threadViewModel.displayName - self.isNoteToSelf = threadViewModel.threadIsNoteToSelf - self.isMessageRequest = (threadViewModel.threadIsMessageRequest == true) - self.showProBadge = (profileCache[threadViewModel.threadId]?.proFeatures.contains(.proBadge) == true) - self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadViewModel.threadMutedUntilTimestamp ?? 0)) - self.onlyNotifyForMentions = (threadViewModel.threadOnlyNotifyForMentions == true) - self.userCount = threadViewModel.userCount - self.disappearingMessagesConfig = threadViewModel.disappearingMessagesConfiguration + self.threadVariant = threadInfo.variant + self.displayName = threadInfo.displayName.deformatted() + self.isNoteToSelf = threadInfo.isNoteToSelf + self.isMessageRequest = threadInfo.isMessageRequest + self.showProBadge = (dataCache.profile(for: threadInfo.id)?.proFeatures.contains(.proBadge) == true) + self.isMuted = (dependencies.dateNow.timeIntervalSince1970 <= (threadInfo.mutedUntilTimestamp ?? 0)) + self.onlyNotifyForMentions = threadInfo.onlyNotifyForMentions + self.userCount = threadInfo.userCount + self.disappearingMessagesConfig = threadInfo.disappearingMessagesConfiguration } } -// MARK: - Convenience - public extension ConversationViewModel { - static func fetchThreadViewModel( + static func fetchConversationInfo( threadId: String, - variant: SessionThread.Variant, using dependencies: Dependencies - ) async throws -> SessionThreadViewModel { - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard variant == .group else { return (false, false) } - - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)), - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - ) - } - }() - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentUserSessionIds: Set = await { - guard - variant == .community, - let serverInfo: CommunityManager.Server = await dependencies[singleton: .communityManager].server(threadId: threadId) - else { return [userSessionId.hexString] } - - return serverInfo.currentUserSessionIds - }() - + ) async throws -> ConversationInfoViewModel { return try await dependencies[singleton: .storage].readAsync { [dependencies] db in - try ConversationViewModel.fetchThreadViewModel( + try ConversationViewModel.fetchConversationInfo( db, threadId: threadId, - userSessionId: userSessionId, - currentUserSessionIds: currentUserSessionIds, - threadWasKickedFromGroup: wasKickedFromGroup, - threadGroupIsDestroyed: groupIsDestroyed, using: dependencies ) } } - static func fetchThreadViewModel( + static func fetchConversationInfo( _ db: ObservingDatabase, threadId: String, - userSessionId: SessionId, - currentUserSessionIds: Set, - threadWasKickedFromGroup: Bool, - threadGroupIsDestroyed: Bool, using dependencies: Dependencies - ) throws -> SessionThreadViewModel { - let threadData: SessionThreadViewModel = try SessionThreadViewModel - .conversationQuery( - threadId: threadId, - userSessionId: userSessionId + ) throws -> ConversationInfoViewModel { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + var dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .messageList(threadId: threadId), + requireFullRefresh: true, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false ) - .fetchOne(db) ?? { throw StorageError.objectNotFound }() - let threadRecentReactionEmoji: [String]? = try Emoji.getRecent(db, withDefaultEmoji: true) - var threadOpenGroupCapabilities: Set? - - if threadData.threadVariant == .community { - threadOpenGroupCapabilities = try Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == threadData.openGroupServer?.lowercased()) - .filter(Capability.Columns.isMissing == false) - .asRequest(of: Capability.Variant.self) - .fetchSet(db) - } + ) + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + threadIdsNeedingFetch: [threadId] + ) - return threadData.populatingPostQueryData( - recentReactionEmoji: threadRecentReactionEmoji, - openGroupCapabilities: threadOpenGroupCapabilities, - currentUserSessionIds: currentUserSessionIds, - wasKickedFromGroup: threadWasKickedFromGroup, - groupIsDestroyed: threadGroupIsDestroyed, - threadCanWrite: threadData.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: threadData.determineInitialCanUploadFlag(using: dependencies) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies ) - } -} - -private extension SessionId.Prefix { - static func isCommunityBlinded(_ id: String?) -> Bool { - switch try? SessionId.Prefix(from: id) { - case .blinded15, .blinded25: return true - case .standard, .unblinded, .group, .versionBlinded07, .none: return false + dataCache = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + using: dependencies + ) + + guard let thread: SessionThread = dataCache.thread(for: threadId) else { + Log.error(.conversation, "Unable to fetch conversation info for thread: \(threadId).") + throw StorageError.objectNotFound } + + return ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) } } diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index 095613bf5e..33b9be4e90 100644 --- a/Session/Conversations/Message Cells/CallMessageCell.swift +++ b/Session/Conversations/Message Cells/CallMessageCell.swift @@ -124,7 +124,6 @@ final class CallMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard @@ -174,7 +173,7 @@ final class CallMessageCell: MessageCell { ) infoImageView.isHidden = !shouldShowInfoIcon - label.text = cellViewModel.body + label.text = cellViewModel.bubbleBody // Timer if diff --git a/Session/Conversations/Message Cells/DateHeaderCell.swift b/Session/Conversations/Message Cells/DateHeaderCell.swift index 1bb94fc3b2..f06c123333 100644 --- a/Session/Conversations/Message Cells/DateHeaderCell.swift +++ b/Session/Conversations/Message Cells/DateHeaderCell.swift @@ -46,7 +46,6 @@ final class DateHeaderCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .dateHeader else { return } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index be513f053c..b0b9a523aa 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -87,7 +87,6 @@ final class InfoMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.variant.isInfoMessage else { return } @@ -119,7 +118,7 @@ final class InfoMessageCell: MessageCell { iconImageView.themeTintColor = .textSecondary } - self.label.themeAttributedText = cellViewModel.body?.formatted(in: self.label) + self.label.themeAttributedText = cellViewModel.bubbleBody?.formatted(in: self.label) if cellViewModel.canFollowDisappearingMessagesSetting { self.actionLabel.isHidden = false diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 782a784f24..61cacf1bee 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -82,7 +82,6 @@ public class MessageCell: UITableViewCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { preconditionFailure("Must be overridden by subclasses.") diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 307d5b52c2..42b530ffc3 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -47,7 +47,6 @@ final class TypingIndicatorCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .typingIndicator else { return } diff --git a/Session/Conversations/Message Cells/UnreadMarkerCell.swift b/Session/Conversations/Message Cells/UnreadMarkerCell.swift index 8926b11d8c..12313e50d5 100644 --- a/Session/Conversations/Message Cells/UnreadMarkerCell.swift +++ b/Session/Conversations/Message Cells/UnreadMarkerCell.swift @@ -67,7 +67,6 @@ final class UnreadMarkerCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { guard cellViewModel.cellType == .unreadMarker else { return } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index fd48ed0f0a..f1d9b169af 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -318,7 +318,6 @@ final class VisibleMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { self.dependencies = dependencies @@ -375,7 +374,6 @@ final class VisibleMessageCell: MessageCell { shouldExpanded: shouldExpanded, lastSearchText: lastSearchText, tableSize: tableSize, - displayNameRetriever: displayNameRetriever, using: dependencies ) @@ -490,7 +488,6 @@ final class VisibleMessageCell: MessageCell { shouldExpanded: Bool, lastSearchText: String?, tableSize: CGSize, - displayNameRetriever: DisplayNameRetriever, using dependencies: Dependencies ) { let bodyLabelTextColor: ThemeValue = cellViewModel.bodyTextColor @@ -574,8 +571,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - displayNameRetriever: displayNameRetriever + searchText: lastSearchText ) bodyTappableLabelContainer.addSubview(bodyTappableInfo.label) @@ -640,8 +636,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - displayNameRetriever: displayNameRetriever + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel self.bodyLabelHeight = height @@ -701,7 +696,7 @@ final class VisibleMessageCell: MessageCell { ) let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight - switch (cellViewModel.quoteViewModel, cellViewModel.body) { + switch (cellViewModel.quoteViewModel, cellViewModel.bubbleBody) { /// Both quote and body case (.some(let quoteViewModel), .some(let body)) where !body.isEmpty: // Stack view @@ -724,8 +719,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - displayNameRetriever: displayNameRetriever + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel self.bodyLabelHeight = height @@ -757,8 +751,7 @@ final class VisibleMessageCell: MessageCell { for: cellViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: lastSearchText, - displayNameRetriever: displayNameRetriever + searchText: lastSearchText ) self.bodyLabel = bodyTappableLabel @@ -1278,27 +1271,24 @@ final class VisibleMessageCell: MessageCell { static func getBodyAttributedText( for cellViewModel: MessageViewModel, textColor: ThemeValue, - searchText: String?, - displayNameRetriever: DisplayNameRetriever + searchText: String? ) -> ThemedAttributedString? { guard - let body: String = cellViewModel.body, + let body: String = cellViewModel.bubbleBody, !body.isEmpty else { return nil } let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing) - let attributedText: ThemedAttributedString = MentionUtilities.highlightMentions( - in: body, - currentUserSessionIds: cellViewModel.currentUserSessionIds, - location: (isOutgoing ? .outgoingMessage : .incomingMessage), - textColor: textColor, - attributes: [ - .themeForegroundColor: textColor, - .font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) - ], - displayNameRetriever: displayNameRetriever, - currentUserMentionImage: cellViewModel.currentUserMentionImage - ) + let attributedText: ThemedAttributedString = body + .formatted( + baseFont: .systemFont(ofSize: getFontSize(for: cellViewModel)), + attributes: [.themeForegroundColor: textColor], + mentionColor: MentionUtilities.mentionColor( + textColor: textColor, + location: (isOutgoing ? .outgoingMessage : .incomingMessage) + ), + currentUserMentionImage: cellViewModel.currentUserMentionImage + ) // Custom handle links let links: [URL: NSRange] = { @@ -1358,46 +1348,15 @@ final class VisibleMessageCell: MessageCell { // If there is a valid search term then highlight each part that matched if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength { - let normalizedBody: String = attributedText.string.lowercased() + let ranges: [NSRange] = GlobalSearch.ranges( + for: searchText, + in: attributedText.string + ) - SessionThreadViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - let partRange = (part.index(after: part.startIndex).. = { - let term: String = String(normalizedBody[range]) - - // If the matched term doesn't actually match the "part" value then it means - // we've matched a term after a non-alphanumeric character so need to shift - // the range over by 1 - guard term.starts(with: part.lowercased()) else { - return (normalizedBody.index(after: range.lowerBound).. (label: LinkHighlightingLabel, height: CGFloat) { let attributedText: ThemedAttributedString? = VisibleMessageCell.getBodyAttributedText( for: cellViewModel, textColor: textColor, - searchText: searchText, - displayNameRetriever: displayNameRetriever + searchText: searchText ) let result: LinkHighlightingLabel = LinkHighlightingLabel() result.setContentHugging(.vertical, to: .required) diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index d2e82f519d..7482436628 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -20,8 +20,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga private let threadId: String private let threadVariant: SessionThread.Variant private var isNoteToSelf: Bool - private let currentUserIsClosedGroupMember: Bool? - private let currentUserIsClosedGroupAdmin: Bool? + private let currentUserRole: GroupMember.Role? private let originalConfig: DisappearingMessagesConfiguration private var configSubject: CurrentValueSubject @@ -30,8 +29,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga init( threadId: String, threadVariant: SessionThread.Variant, - currentUserIsClosedGroupMember: Bool?, - currentUserIsClosedGroupAdmin: Bool?, + currentUserRole: GroupMember.Role?, config: DisappearingMessagesConfiguration, using dependencies: Dependencies ) { @@ -39,8 +37,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga self.threadId = threadId self.threadVariant = threadVariant self.isNoteToSelf = (threadId == dependencies[cache: .general].sessionId.hexString) - self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember - self.currentUserIsClosedGroupAdmin = currentUserIsClosedGroupAdmin + self.currentUserRole = currentUserRole self.originalConfig = config self.configSubject = CurrentValueSubject(config) } @@ -271,7 +268,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga ), isEnabled: ( isNoteToSelf || - currentUserIsClosedGroupAdmin == true + currentUserRole == .admin ), accessibility: Accessibility( identifier: "Disable disappearing messages (Off option)", @@ -306,7 +303,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga identifier: "\(title) - Radio" ) ), - isEnabled: (isNoteToSelf || currentUserIsClosedGroupAdmin == true), + isEnabled: (isNoteToSelf || currentUserRole == .admin), accessibility: Accessibility( identifier: "Time option", label: "Time option" @@ -400,10 +397,11 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, Naviga /// Notify of update db.addConversationEvent( id: threadId, + variant: threadVariant, type: .updated(.disappearingMessageConfiguration(updatedConfig)) ) } } } -extension String: Differentiable {} +extension String: @retroactive Differentiable {} diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 6353415c5e..d9848f5fa5 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -13,14 +13,20 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionNetworkingKit +// MARK: - Log.Category + +public extension Log.Category { + static let threadSettingsViewModel: Log.Category = .create("ThreadSettingsViewModel", defaultLevel: .warn) +} + +// MARK: - ThreadSettingsViewModel + class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() - private let threadId: String - private let threadVariant: SessionThread.Variant private let didTriggerSearch: () -> () private var updatedName: String? private var updatedDescription: String? @@ -32,31 +38,70 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi }, using: dependencies ) - private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) - // TODO: Refactor this with SessionThreadViewModel - private var threadViewModelSubject: CurrentValueSubject + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: State + private var observationTask: Task? // MARK: - Initialization - init( - threadId: String, - threadVariant: SessionThread.Variant, + @MainActor init( + threadInfo: ConversationInfoViewModel, didTriggerSearch: @escaping () -> (), using dependencies: Dependencies ) { self.dependencies = dependencies - self.threadId = threadId - self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch - self.threadViewModelSubject = CurrentValueSubject(nil) - self.profileImageStatus = (previous: nil, current: .normal) + self.internalState = State.initialState(threadInfo: threadInfo, using: dependencies) + self.observationTask = nil + + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .milliseconds(10)) /// Changes trigger multiple events at once so debounce them + .using(dependencies: dependencies) + .query(ThreadSettingsViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) + } } // MARK: - Config - enum ProfileImageStatus: Equatable { - case normal - case expanded - case qrCode + + enum ProfileImageStatus: Equatable, Hashable { + case image(expanded: Bool) + case qrCode(expanded: Bool) + + var isQRCode: Bool { + switch self { + case .image: return false + case .qrCode: return true + } + } + + var isExpanded: Bool { + switch self { + case .image(let expanded), .qrCode(let expanded): return expanded + } + } + + func toggleState() -> ProfileImageStatus { + switch self { + case .image(let expanded): return .qrCode(expanded: expanded) + case .qrCode(let expanded): return .image(expanded: expanded) + } + } + + func toggleExpansion() -> ProfileImageStatus { + switch self { + case .image(let expanded): return .image(expanded: !expanded) + case .qrCode(let expanded): return .qrCode(expanded: !expanded) + } + } } enum NavItem: Equatable { @@ -121,20 +166,76 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case debugDeleteAttachmentsBeforeNow } - lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject - .map { [weak self] threadViewModel -> [SessionNavItem] in - guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } - - let currentUserIsClosedGroupAdmin: Bool = ( - [.legacyGroup, .group].contains(threadViewModel.threadVariant) && - threadViewModel.currentUserIsClosedGroupAdmin == true + public struct ThreadSettingsViewModelEvent: Hashable { + let profileImageStatus: ProfileImageStatus + } + + // MARK: - State + + public struct State: ObservableKeyProvider { + let profileImageStatus: ProfileImageStatus + let isProConversation: Bool = false // TODO: [PRO] Need to determine whether it's a PRO group conversation + + let threadInfo: ConversationInfoViewModel + let dataCache: ConversationDataCache + + @MainActor public func sections(viewModel: ThreadSettingsViewModel) -> [SectionModel] { + ThreadSettingsViewModel.sections(state: self, viewModel: viewModel) + } + + public var observedKeys: Set { + var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), + .updateScreen(ThreadSettingsViewModel.self), + .conversationUpdated(threadInfo.id), + .conversationDeleted(threadInfo.id), + .profile(threadInfo.userSessionId.hexString), + .typingIndicator(threadInfo.id), + .messageCreated(threadId: threadInfo.id), + .recentReactionsUpdated + ] + + if SessionId.Prefix.isCommunityBlinded(threadInfo.id) { + result.insert(.anyContactUnblinded) + } + + result.insert(contentsOf: threadInfo.observedKeys) + + return result + } + + static func initialState( + threadInfo: ConversationInfoViewModel, + using dependencies: Dependencies + ) -> State { + let dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ) + return State( + profileImageStatus: .image(expanded: false), + threadInfo: threadInfo, + dataCache: dataCache + ) + } + } + + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = $internalState + .map { [weak self] state -> [SessionNavItem] in let canEditDisplayName: Bool = ( - threadViewModel.threadIsNoteToSelf != true && + !state.threadInfo.isNoteToSelf && ( - threadViewModel.threadVariant == .contact || - currentUserIsClosedGroupAdmin + state.threadInfo.variant == .contact || + state.threadInfo.groupInfo?.currentUserRole == .admin ) ) @@ -148,12 +249,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi style: .plain, accessibilityIdentifier: "Edit Nickname", action: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal(state: state) else { + return + } self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) } @@ -164,80 +262,122 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Content - private struct State: Equatable { - let threadViewModel: SessionThreadViewModel? - let disappearingMessagesConfig: DisappearingMessagesConfiguration - let isProConversation: Bool - let shouldShowProBadge: Bool - } - - var title: String { - switch threadVariant { + @MainActor var title: String { + switch internalState.threadInfo.variant { case .contact: return "sessionSettings".localized() case .legacyGroup, .group, .community: return "deleteAfterGroupPR1GroupSettings".localized() } } - lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel - .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) - let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration - .fetchOne(db, id: threadId) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) - - self?.threadViewModelSubject.send(threadViewModel) - - return State( - threadViewModel: threadViewModel, - disappearingMessagesConfig: disappearingMessagesConfig, - isProConversation: false, // TODO: [PRO] Need to source this - shouldShowProBadge: false // TODO: [PRO] Need to source this + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + var profileImageStatus: ProfileImageStatus = previousState.profileImageStatus + var threadInfo: ConversationInfoViewModel = previousState.threadInfo + var dataCache: ConversationDataCache = previousState.dataCache + + /// If there are no events we want to process then just return the current state + guard isInitialQuery || !events.isEmpty else { return previousState } + + /// Split the events between those that need database access and those that don't + let changes: EventChangeset = events.split(by: { $0.handlingStrategy }) + + /// Update the context + dataCache.withContext( + source: .conversationList, + requireFullRefresh: ( + isInitialQuery || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) ) + ) + + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: [threadInfo.id: threadInfo], + loadPageEvent: nil + ) + + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.threadSettingsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } } - .compactMap { [weak self] current -> [SectionModel]? in - self?.content( - current, - profileImageStatus: self?.profileImageStatus - ) + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { + do { + try await dependencies[singleton: .storage].readAsync { db in + /// Fetch any required data from the cache + dataCache = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + using: dependencies + ) + } + } catch { + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.threadSettingsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + } } - - private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { - // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted - // so dismiss the screen - guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { - self.dismissScreen(type: .popToRoot) - return [] + else if !changes.databaseEvents.isEmpty { + Log.warn(.threadSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } - let isGroup: Bool = ( - threadViewModel.threadVariant == .legacyGroup || - threadViewModel.threadVariant == .group - ) - let currentUserKickedFromGroup: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupMember != true - ) + if let updatedValue: ThreadSettingsViewModelEvent = changes.latestGeneric(.updateScreen, as: ThreadSettingsViewModelEvent.self) { + profileImageStatus = updatedValue.profileImageStatus + } - let currentUserIsClosedGroupMember: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupMember == true - ) - let currentUserIsClosedGroupAdmin: Bool = ( - isGroup && - threadViewModel.currentUserIsClosedGroupAdmin == true + /// Regenerate the `threadInfo` now that the `dataCache` is updated + if let thread: SessionThread = dataCache.thread(for: threadInfo.id) { + threadInfo = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + + /// Generate the new state + return State( + profileImageStatus: profileImageStatus, + threadInfo: threadInfo, + dataCache: dataCache ) + } + + private static func sections(state: State, viewModel: ThreadSettingsViewModel) -> [SectionModel] { + let threadDisplayName: String = state.threadInfo.displayName.deformatted() let isThreadHidden: Bool = ( - threadViewModel.threadShouldBeVisible != true && - threadViewModel.threadPinnedPriority == LibSession.hiddenPriority + !state.threadInfo.shouldBeVisible && + state.threadInfo.pinnedPriority == LibSession.hiddenPriority ) - let showThreadPubkey: Bool = ( - threadViewModel.threadVariant == .contact || ( - threadViewModel.threadVariant == .group && - dependencies[feature: .groupsShowPubkeyInConversationSettings] + state.threadInfo.variant == .contact || ( + state.threadInfo.variant == .group && + viewModel.dependencies[feature: .groupsShowPubkeyInConversationSettings] ) ) // MARK: - Conversation Info @@ -245,11 +385,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - (profileImageStatus?.current == .qrCode ? + (state.profileImageStatus.isQRCode ? SessionCell.Info( id: .qrCode, accessory: .qrCode( - for: threadViewModel.getQRCodeString(), + for: state.threadInfo.qrCodeString, hasBackground: false, logo: "SessionWhite40", // stringlint:ignore themeStyle: ThemeManager.currentTheme.interfaceStyle @@ -259,14 +399,19 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi customPadding: SessionCell.Padding(bottom: Values.smallSpacing), backgroundStyle: .noBackground ), - onTapView: { [weak self] targetView in + onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in let didTapProfileIcon: Bool = !(targetView is UIImageView) if didTapProfileIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) - self?.forceRefresh(type: .postDatabaseQuery) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleState() + ) + ) } else { - self?.showQRCodeLightBox(for: threadViewModel) + viewModel?.showQRCodeLightBox(for: state.threadInfo) } } ) @@ -274,13 +419,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SessionCell.Info( id: .avatar, accessory: .profile( - id: threadViewModel.id, - size: (profileImageStatus?.current == .expanded ? .expanded : .hero), - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode), - additionalProfile: threadViewModel.additionalProfile, + id: state.threadInfo.id, + size: (state.profileImageStatus.isExpanded ? .expanded : .hero), + threadVariant: state.threadInfo.variant, + displayPictureUrl: state.threadInfo.displayPictureUrl, + profile: state.threadInfo.profile, + profileIcon: (state.threadInfo.isNoteToSelf || state.threadInfo.variant == .group ? .none : .qrCode), + additionalProfile: state.threadInfo.additionalProfile, accessibility: nil ), styling: SessionCell.StyleInfo( @@ -291,31 +436,39 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), backgroundStyle: .noBackground ), - onTapView: { [weak self] targetView in + onTapView: { [dependencies = viewModel.dependencies] targetView in let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) if didTapQRCodeIcon { - self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleState() + ) + ) } else { - self?.profileImageStatus = ( - previous: profileImageStatus?.current, - current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(ThreadSettingsViewModel.self), + value: ThreadSettingsViewModelEvent( + profileImageStatus: state.profileImageStatus.toggleExpansion() + ) ) } - self?.forceRefresh(type: .postDatabaseQuery) } ) ), SessionCell.Info( id: .displayName, title: SessionCell.TextInfo( - threadViewModel.displayName, + threadDisplayName, font: .titleLarge, alignment: .center, trailingImage: { guard - current.shouldShowProBadge && - !threadViewModel.threadIsNoteToSelf + state.threadInfo.shouldShowProBadge && + !state.threadInfo.isNoteToSelf else { return nil } return SessionProBadge.trailingImage( @@ -329,8 +482,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi customPadding: SessionCell.Padding( top: Values.smallSpacing, bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } - guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } + guard state.threadInfo.variant != .contact else { return Values.mediumSpacing } + guard state.threadInfo.conversationDescription == nil else { + return Values.smallSpacing + } return Values.largeSpacing }(), @@ -340,30 +495,27 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), accessibility: Accessibility( identifier: "Username", - label: threadViewModel.displayName + label: threadDisplayName ), - onTapView: { [weak self, dependencies] targetView in + onTapView: { [weak viewModel, dependencies = viewModel.dependencies] targetView in guard targetView is SessionProBadge, !dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro else { - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + guard let info: ConfirmationModal.Info = viewModel?.updateDisplayNameModal(state: state) else { + return + } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + viewModel?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) return } let proCTAModalVariant: ProCTAModal.Variant = { - switch threadViewModel.threadVariant { + switch state.threadInfo.variant { case .group: return .groupLimit( - isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: current.isProConversation, + isAdmin: (state.threadInfo.groupInfo?.currentUserRole == .admin), + isSessionProActivated: state.isProConversation, proBadgeImage: UIView.image( for: .themedKey( SessionProBadge.Size.mini.cacheKey, @@ -379,18 +531,18 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( proCTAModalVariant, onConfirm: {}, - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) + presenting: { [weak viewModel] modal in + viewModel?.transitionToScreen(modal, transitionType: .present) } ) } ), - (threadViewModel.displayName == threadViewModel.contactDisplayName ? nil : + (state.threadInfo.contactInfo == nil || threadDisplayName == state.threadInfo.contactInfo?.displayName ? nil : SessionCell.Info( id: .contactName, subtitle: SessionCell.TextInfo( - "(\(threadViewModel.contactDisplayName))", // stringlint:ignore + "(\(state.threadInfo.contactInfo?.displayName ?? ""))", // stringlint:ignore font: .subtitle, alignment: .center ), @@ -405,11 +557,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - threadViewModel.threadDescription.map { threadDescription in + state.threadInfo.conversationDescription.map { conversationDescription in SessionCell.Info( id: .threadDescription, description: SessionCell.TextInfo( - threadDescription, + conversationDescription, font: .subtitle, alignment: .center, interaction: .expandable @@ -418,13 +570,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi tintColor: .textSecondary, customPadding: SessionCell.Padding( top: 0, - bottom: (threadViewModel.threadVariant != .contact ? Values.largeSpacing : nil) + bottom: (state.threadInfo.variant != .contact ? Values.largeSpacing : nil) ), backgroundStyle: .noBackground ), accessibility: Accessibility( identifier: "Description", - label: threadDescription + label: conversationDescription ) ) } @@ -434,12 +586,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Session Id let sessionIdSection: SectionModel = SectionModel( - model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), + model: (state.threadInfo.isNoteToSelf ? .sessionIdNoteToSelf : .sessionId), elements: [ SessionCell.Info( id: .sessionId, subtitle: SessionCell.TextInfo( - threadViewModel.id, + state.threadInfo.id, font: .monoLarge, alignment: .center, interaction: .copy @@ -450,7 +602,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), accessibility: Accessibility( identifier: "Session ID", - label: threadViewModel.id + label: state.threadInfo.id ) ) ] @@ -458,7 +610,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Users kicked from groups - guard !currentUserKickedFromGroup else { + guard state.threadInfo.groupInfo?.wasKicked != true else { return [ conversationInfoSection, SectionModel( @@ -477,21 +629,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "groupDelete".localized(), body: .attributedText( "groupDeleteDescriptionMember" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .leaveGroupAsync, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -508,11 +660,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let standardActionsSection: SectionModel = SectionModel( model: .content, elements: [ - (threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil : + (state.threadInfo.variant == .legacyGroup || state.threadInfo.variant == .group ? nil : SessionCell.Info( id: .copyThreadId, leadingAccessory: .icon(.copy), - title: (threadViewModel.threadVariant == .community ? + title: (state.threadInfo.variant == .community ? "communityUrlCopy".localized() : "accountIDCopy".localized() ), @@ -520,24 +672,25 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).copy_thread_id", label: "Copy Session ID" ), - onTap: { [weak self] in - switch threadViewModel.threadVariant { + onTap: { [weak viewModel] in + switch state.threadInfo.variant { case .contact, .legacyGroup, .group: - UIPasteboard.general.string = threadViewModel.threadId + UIPasteboard.general.string = state.threadInfo.id case .community: guard + let communityInfo: ConversationInfoViewModel.CommunityInfo = state.threadInfo.communityInfo, let urlString: String = LibSession.communityUrlFor( - server: threadViewModel.openGroupServer, - roomToken: threadViewModel.openGroupRoomToken, - publicKey: threadViewModel.openGroupPublicKey + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey ) else { return } UIPasteboard.general.string = urlString } - self?.showToast( + viewModel?.showToast( text: "copied".localized(), backgroundColor: .backgroundSecondary ) @@ -553,40 +706,42 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).search", label: "Search" ), - onTap: { [weak self] in self?.didTriggerSearch() } + onTap: { [weak viewModel] in viewModel?.didTriggerSearch() } ), ( - threadViewModel.threadVariant == .community || - threadViewModel.threadIsBlocked == true || - currentUserIsClosedGroupAdmin ? nil : + state.threadInfo.variant == .community || + state.threadInfo.isBlocked || + state.threadInfo.groupInfo?.currentUserRole == .admin ? nil : SessionCell.Info( id: .disappearingMessages, leadingAccessory: .icon(.timer), title: "disappearingMessages".localized(), subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } + guard + let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration, + config.isEnabled + else { return "off".localized() } - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString - ) + return (config.type ?? .unknown).localizedState( + durationString: config.durationString + ) }(), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin, - config: current.disappearingMessagesConfig, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + currentUserRole: state.threadInfo.groupInfo?.currentUserRole, + config: ( + state.threadInfo.disappearingMessagesConfiguration ?? + DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id) + ), using: dependencies ) ) @@ -595,16 +750,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadIsBlocked == true ? nil : + (state.threadInfo.isBlocked ? nil : SessionCell.Info( id: .pinConversation, leadingAccessory: .icon( - (threadViewModel.threadPinnedPriority > 0 ? + (state.threadInfo.pinnedPriority > 0 ? .pinOff : .pin ) ), - title: (threadViewModel.threadPinnedPriority > 0 ? + title: (state.threadInfo.pinnedPriority > 0 ? "pinUnpinConversation".localized() : "pinConversation".localized() ), @@ -612,26 +767,26 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).pin_conversation", label: "Pin Conversation" ), - onTap: { [weak self] in + onTap: { [weak viewModel] in Task { - await self?.toggleConversationPinnedStatus( - currentPinnedPriority: threadViewModel.threadPinnedPriority + await viewModel?.toggleConversationPinnedStatus( + threadInfo: state.threadInfo ) } } ) ), - ((threadViewModel.threadIsNoteToSelf == true || threadViewModel.threadIsBlocked == true) ? nil : + (state.threadInfo.isNoteToSelf || state.threadInfo.isBlocked ? nil : SessionCell.Info( id: .notifications, leadingAccessory: .icon( { - if threadViewModel.threadOnlyNotifyForMentions == true { + if state.threadInfo.onlyNotifyForMentions { return .atSign } - if threadViewModel.threadMutedUntilTimestamp != nil { + if state.threadInfo.mutedUntilTimestamp != nil { return .volumeOff } @@ -640,11 +795,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), title: "sessionNotifications".localized(), subtitle: { - if threadViewModel.threadOnlyNotifyForMentions == true { + if state.threadInfo.onlyNotifyForMentions { return "notificationsMentionsOnly".localized() } - if threadViewModel.threadMutedUntilTimestamp != nil { + if state.threadInfo.mutedUntilTimestamp != nil { return "notificationsMuted".localized() } @@ -654,14 +809,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).notifications", label: "Notifications" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadNotificationSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - threadOnlyNotifyForMentions: threadViewModel.threadOnlyNotifyForMentions, - threadMutedUntilTimestamp: threadViewModel.threadMutedUntilTimestamp, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + threadOnlyNotifyForMentions: state.threadInfo.onlyNotifyForMentions, + threadMutedUntilTimestamp: state.threadInfo.mutedUntilTimestamp, using: dependencies ) ) @@ -670,7 +825,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadVariant != .community ? nil : + (state.threadInfo.variant != .community ? nil : SessionCell.Info( id: .addToOpenGroup, leadingAccessory: .icon(.userRoundPlus), @@ -678,11 +833,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi accessibility: Accessibility( identifier: "\(ThreadSettingsViewModel.self).add_to_open_group" ), - onTap: { [weak self] in self?.inviteUsersToCommunity(threadViewModel: threadViewModel) } + onTap: { [weak viewModel] in viewModel?.inviteUsersToCommunity(threadInfo: state.threadInfo) } ) ), - (!currentUserIsClosedGroupMember ? nil : + (state.threadInfo.groupInfo?.currentUserRole == nil ? nil : SessionCell.Info( id: .groupMembers, leadingAccessory: .icon(.usersRound), @@ -691,7 +846,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Group members", label: "Group members" ), - onTap: { [weak self] in self?.viewMembers() } + onTap: { [weak viewModel] in viewModel?.viewMembers(state: state) } ) ), @@ -703,12 +858,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "\(ThreadSettingsViewModel.self).all_media", label: "All media" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( MediaGalleryViewModel.createAllMediaViewController( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - threadTitle: threadViewModel.displayName, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + threadTitle: threadDisplayName, focusedAttachmentId: nil, using: dependencies ) @@ -721,7 +876,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Admin Actions let adminActionsSection: SectionModel? = ( - !currentUserIsClosedGroupAdmin ? nil : + state.threadInfo.groupInfo?.currentUserRole != .admin ? nil : SectionModel( model: .adminActions, elements: [ @@ -733,11 +888,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Edit group", label: "Edit group" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: EditGroupViewModel( - threadId: threadViewModel.threadId, + threadId: state.threadInfo.id, using: dependencies ) ) @@ -745,7 +900,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } ), - (!dependencies[feature: .updatedGroupsAllowPromotions] ? nil : + (!viewModel.dependencies[feature: .updatedGroupsAllowPromotions] ? nil : SessionCell.Info( id: .promoteAdmins, leadingAccessory: .icon( @@ -757,8 +912,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Promote admins", label: "Promote admins" ), - onTap: { [weak self] in - self?.promoteAdmins(currentGroupName: threadViewModel.closedGroupName) + onTap: { [weak viewModel] in + viewModel?.promoteAdmins(state: state) } ) ), @@ -768,28 +923,29 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi leadingAccessory: .icon(.timer), title: "disappearingMessages".localized(), subtitle: { - guard current.disappearingMessagesConfig.isEnabled else { - return "off".localized() - } + guard + let config: DisappearingMessagesConfiguration = state.threadInfo.disappearingMessagesConfiguration, + config.isEnabled + else { return "off".localized() } - return (current.disappearingMessagesConfig.type ?? .unknown) - .localizedState( - durationString: current.disappearingMessagesConfig.durationString - ) + return (config.type ?? .unknown) + .localizedState(durationString: config.durationString) }(), accessibility: Accessibility( identifier: "Disappearing messages", label: "\(ThreadSettingsViewModel.self).disappearing_messages" ), - onTap: { [weak self, dependencies] in - self?.transitionToScreen( + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.transitionToScreen( SessionTableViewController( viewModel: ThreadDisappearingMessagesSettingsViewModel( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - currentUserIsClosedGroupMember: threadViewModel.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: threadViewModel.currentUserIsClosedGroupAdmin, - config: current.disappearingMessagesConfig, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + currentUserRole: state.threadInfo.groupInfo?.currentUserRole, + config: ( + state.threadInfo.disappearingMessagesConfiguration ?? + DisappearingMessagesConfiguration.defaultWith(state.threadInfo.id) + ), using: dependencies ) ) @@ -805,18 +961,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let destructiveActionsSection: SectionModel = SectionModel( model: .destructiveActions, elements: [ - (threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil : + (state.threadInfo.isNoteToSelf || state.threadInfo.variant != .contact ? nil : SessionCell.Info( id: .blockUser, - leadingAccessory: ( - threadViewModel.threadIsBlocked == true ? - .icon(.userRoundCheck) : - .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) + leadingAccessory: (state.threadInfo.isBlocked ? + .icon(.userRoundCheck) : + .icon(UIImage(named: "ic_user_round_ban")?.withRenderingMode(.alwaysTemplate)) ), - title: ( - threadViewModel.threadIsBlocked == true ? - "blockUnblock".localized() : - "block".localized() + title: (state.threadInfo.isBlocked ? + "blockUnblock".localized() : + "block".localized() ), styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( @@ -824,14 +978,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi label: "Block" ), confirmationInfo: ConfirmationModal.Info( - title: (threadViewModel.threadIsBlocked == true ? + title: (state.threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), - body: (threadViewModel.threadIsBlocked == true ? + body: (state.threadInfo.isBlocked ? .attributedText( "blockUnblockName" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( @@ -1387,7 +1541,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi public static func createMemberListViewController( threadId: String, - transitionToConversation: @escaping @MainActor (SessionThreadViewModel?) -> Void, + transitionToConversation: @escaping @MainActor (ConversationInfoViewModel?) -> Void, using dependencies: Dependencies ) -> UIViewController { return SessionTableViewController( @@ -1405,8 +1559,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .filter(GroupMember.Columns.groupId == threadId) .group(GroupMember.Columns.profileId), onTap: .callback { _, memberInfo in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let maybeThreadViewModel: SessionThreadViewModel? = try? await dependencies[singleton: .storage].writeAsync { db in + let maybeThreadInfo: ConversationInfoViewModel? = try? await dependencies[singleton: .storage].writeAsync { db in try SessionThread.upsert( db, id: memberInfo.profileId, @@ -1421,19 +1574,15 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi using: dependencies ) - return try ConversationViewModel.fetchThreadViewModel( + return try ConversationViewModel.fetchConversationInfo( db, threadId: memberInfo.profileId, - userSessionId: userSessionId, - currentUserSessionIds: [userSessionId.hexString], - threadWasKickedFromGroup: false, - threadGroupIsDestroyed: false, using: dependencies ) } await MainActor.run { - transitionToConversation(maybeThreadViewModel) + transitionToConversation(maybeThreadInfo) } }, using: dependencies diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index f6c1aca6d2..311cbd00e2 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -147,13 +148,10 @@ final class ConversationTitleView: UIView { if viewModel.isMuted { let notificationSettingsLabelString = ThemedAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 8) as Any, - .themeForegroundColor: ThemeValue.textPrimary - ] + string: NotificationsUI.mutePrefix.rawValue ) .appending(string: "notificationsMuted".localized()) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize) labelInfos.append( SessionLabelCarouselView.LabelInfo( @@ -164,19 +162,12 @@ final class ConversationTitleView: UIView { ) } else if viewModel.onlyNotifyForMentions { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")? - .withRenderingMode(.alwaysTemplate) - imageAttachment.bounds = CGRect( - x: 0, - y: -2, - width: Values.miniFontSize, - height: Values.miniFontSize + let notificationSettingsLabelString = ThemedAttributedString( + string: NotificationsUI.mentionPrefix.rawValue ) - - let notificationSettingsLabelString = ThemedAttributedString(attachment: imageAttachment) - .appending(string: " ") - .appending(string: "notificationsMentionsOnly".localized()) + .appending(string: " ") + .appending(string: "notificationsMentionsOnly".localized()) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.miniFontSize) labelInfos.append( SessionLabelCarouselView.LabelInfo( diff --git a/Session/Emoji/EmojiWithSkinTones.swift b/Session/Emoji/EmojiWithSkinTones.swift index 776de92dca..084231599a 100644 --- a/Session/Emoji/EmojiWithSkinTones.swift +++ b/Session/Emoji/EmojiWithSkinTones.swift @@ -82,6 +82,7 @@ extension Emoji { .inserting(emoji, at: 0) .prefix(6) .joined(separator: ",") + db.addEvent(.recentReactionsUpdated) } static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: ObservingDatabase) -> [Category: [EmojiWithSkinTones]] { diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 08930798a4..84078159da 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -9,6 +9,9 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +private typealias ConversationSearchResult = GlobalSearch.ConversationSearchResult +private typealias MessageSearchResult = GlobalSearch.MessageSearchResult + // MARK: - Log.Category private extension Log.Category { @@ -18,11 +21,23 @@ private extension Log.Category { // MARK: - GlobalSearchViewController class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource { - fileprivate typealias SectionModel = ArraySection + fileprivate typealias SectionModel = ArraySection - fileprivate struct SearchResultData: Equatable { + fileprivate class SearchResultData: Equatable { var state: SearchResultsState var data: [SectionModel] + + init(state: SearchResultsState, data: [SectionModel]) { + self.state = state + self.data = data + } + + static func == (lhs: SearchResultData, rhs: SearchResultData) -> Bool { + return ( + lhs.state == rhs.state && + lhs.data.count == rhs.data.count + ) + } } enum SearchResultsState: Int, Differentiable { @@ -32,6 +47,7 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } // MARK: - SearchSection + enum SearchSection: Codable, Hashable, Differentiable { case contactsAndGroups case messages @@ -42,9 +58,9 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI let isConversationList: Bool = true - func forceRefreshIfNeeded() { + @MainActor func forceRefreshIfNeeded() { // Need to do this as the 'GlobalSearchViewController' doesn't observe database changes - updateSearchResults(searchText: searchText, force: true) + updateSearchResults(searchText: searchText, currentCache: dataCache, force: true) } // MARK: - Variables @@ -64,18 +80,43 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } } private lazy var defaultSearchResultsObservation = ValueObservation - .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - try SessionThreadViewModel - .defaultContactsQuery(using: dependencies) + .trackingConstantRegion { [dependencies] db -> ([ConversationSearchResult], ConversationDataCache) in + let results: [ConversationSearchResult] = try ConversationSearchResult + .defaultContactsQuery(userSessionId: dependencies[cache: .general].sessionId) .fetchAll(db) + let cache: ConversationDataCache = try ConversationDataHelper.generateCacheForDefaultContacts( + ObservingDatabase.create(db, using: dependencies), + contactIds: results.map { $0.id }, + using: dependencies + ) + + return (results, cache) + } + .map { [dependencies] results, cache in + GlobalSearch.processDefaultSearchResults( + results: results, + cache: cache, + using: dependencies + ) } - .map { GlobalSearchViewController.processDefaultSearchResults($0) } .removeDuplicates() .handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") }) private var defaultDataChangeObservable: DatabaseCancellable? { didSet { oldValue?.cancel() } // Cancel the old observable if there was one } + /// Generating the search results is somewhat inefficient but since the user is typing then caching individual ViewModel values is + /// unlikely to result in any cache hits, the one case where it might is if the user backspaces and enters a new character. In that + /// case it is far simpler to just cache the full result set against the search term (while this could result in stale data, it's unlikely + /// to be an issue as users generally wouldn't sit on the search results screen and expect updates to come through). + private let searchResultCache: NSCache = { + let result: NSCache = NSCache() + result.name = "GlobalSearchResultCache" // stringlint:ignore + result.countLimit = 10 /// Last 10 result sets + + return result + }() + @ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil private lazy var searchResultSet: SearchResultData = defaultSearchResults private var termForCurrentSearchResultSet: String = "" @@ -84,18 +125,30 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI var isLoading = false - @objc public var searchText = "" { + @MainActor public var searchText = "" { didSet { Log.assertOnMainThread() // Use a slight delay to debounce updates. refreshSearchResults() } } + @MainActor private var dataCache: ConversationDataCache // MARK: - Initialization init(using dependencies: Dependencies) { self.dependencies = dependencies + self.dataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .searchResults, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) super.init(nibName: nil, bundle: nil) } @@ -216,74 +269,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI // MARK: - Update Search Results - private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData { - let nonalphabeticNameTitle: String = "#" // stringlint:ignore - - return SearchResultData( - state: .defaultContacts, - data: contacts - .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } - .filter { $0.isContactApproved == true } // Only show default contacts that have been approved via message request - .reduce(into: [String: SectionModel]()) { result, next in - guard !next.threadIsNoteToSelf else { - result[""] = SectionModel( - model: .groupedContacts(title: ""), - elements: [next] - ) - return - } - - let displayName = NSMutableString(string: next.displayName) - CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) - CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) - - let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") - let section: String = (initialCharacter.capitalized.isSingleAlphabet ? - initialCharacter.capitalized : - nonalphabeticNameTitle - ) - - if result[section] == nil { - result[section] = SectionModel( - model: .groupedContacts(title: section), - elements: [] - ) - } - result[section]?.elements.append(next) - } - .values - .sorted { sectionModel0, sectionModel1 in - let title0: String = { - switch sectionModel0.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - let title1: String = { - switch sectionModel1.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - - if ![title0, title1].contains(nonalphabeticNameTitle) { - return title0 < title1 - } - - return title1 == nonalphabeticNameTitle - } - ) - } - private func refreshSearchResults() { refreshTimer?.invalidate() refreshTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 0.1, using: dependencies) { [weak self] _ in - self?.updateSearchResults(searchText: (self?.searchText ?? "")) + guard let self else { return } + + updateSearchResults(searchText: searchText, currentCache: dataCache) } } private func updateSearchResults( searchText rawSearchText: String, + currentCache: ConversationDataCache, force: Bool = false ) { let searchText = rawSearchText.stripped @@ -301,27 +298,62 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI lastSearchText = searchText _currentSearchCancellable.perform { $0?.cancel() } + /// Check for a cache hit before performing the search + if let cachedResult: SearchResultData = searchResultCache.object(forKey: searchText as NSString) { + DispatchQueue.main.async { [weak self] in + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = cachedResult + self?.isLoading = false + self?.tableView.reloadData() + self?.refreshTimer = nil + } + return + } + + let userSessionId: SessionId = dependencies[cache: .general].sessionId _currentSearchCancellable.set(to: dependencies[singleton: .storage] - .readPublisher { [dependencies] db -> [SectionModel] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel - .contactsAndGroupsQuery( + .readPublisher { [dependencies] db -> ([ConversationSearchResult], [MessageSearchResult], ConversationDataCache) in + let searchPattern: FTS5Pattern = try GlobalSearch.pattern(db, searchTerm: searchText) + let conversationResults: [ConversationSearchResult] = try ConversationSearchResult + .query( userSessionId: userSessionId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), + pattern: searchPattern, searchTerm: searchText ) .fetchAll(db) - let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel - .messagesQuery( + let messageResults: [MessageSearchResult] = try MessageSearchResult + .query( userSessionId: userSessionId, - pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + pattern: searchPattern ) .fetchAll(db) + let cache: ConversationDataCache = try ConversationDataHelper.updateCacheForSearchResults( + db, + currentCache: currentCache, + conversationResults: conversationResults, + messageResults: messageResults, + using: dependencies + ) + + return (conversationResults, messageResults, cache) + } + .tryMap { [dependencies] conversationResults, messageResults, cache -> ([SectionModel], ConversationDataCache) in + let (conversationViewModels, messageViewModels) = ConversationDataHelper.processSearchResults( + cache: cache, + searchText: searchText, + conversationResults: conversationResults, + messageResults: messageResults, + userSessionId: userSessionId, + using: dependencies + ) - return [ - ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), - ArraySection(model: .messages, elements: messageResults) - ] + return ( + [ + ArraySection(model: .contactsAndGroups, elements: conversationViewModels), + ArraySection(model: .messages, elements: messageViewModels) + ], + cache + ) } .subscribe(on: DispatchQueue.global(qos: .default), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) @@ -335,13 +367,16 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI Log.error(.cat, "Failed to find results due to error: \(error)") } }, - receiveValue: { [weak self] sections in - self?.termForCurrentSearchResultSet = searchText - self?.searchResultSet = SearchResultData( + receiveValue: { [weak self] sections, updatedCache in + let result: SearchResultData = SearchResultData( state: (sections.map { $0.elements.count }.reduce(0, +) > 0) ? .results : .none, data: sections ) + self?.termForCurrentSearchResultSet = searchText + self?.searchResultSet = result self?.isLoading = false + self?.dataCache = updatedCache + self?.searchResultCache.setObject(result, forKey: searchText as NSString) self?.tableView.reloadData() self?.refreshTimer = nil } @@ -395,8 +430,8 @@ extension GlobalSearchViewController { case .groupedContacts: return nil case .contactsAndGroups, .messages: guard - let interactionId: Int64 = section.elements[indexPath.row].interactionId, - let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs + let interactionId: Int64 = section.elements[indexPath.row].targetInteraction?.id, + let timestampMs: Int64 = section.elements[indexPath.row].targetInteraction?.timestampMs else { return nil } return Interaction.TimestampInfo( @@ -408,7 +443,7 @@ extension GlobalSearchViewController { Task.detached(priority: .userInitiated) { [weak self] in await self?.show( - threadViewModel: section.elements[indexPath.row], + viewModel: section.elements[indexPath.row], focusedInteractionInfo: focusedInteractionInfo ) } @@ -420,10 +455,10 @@ extension GlobalSearchViewController { switch section.model { case .contactsAndGroups, .messages: return nil case .groupedContacts: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let viewModel: ConversationInfoViewModel = section.elements[indexPath.row] /// No actions for `Note to Self` - guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard !viewModel.isNoteToSelf else { return nil } return UIContextualAction.configuration( for: UIContextualAction.generateSwipeActions( @@ -431,7 +466,7 @@ extension GlobalSearchViewController { for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: viewModel, viewController: self, navigatableStateHolder: nil, using: dependencies @@ -441,17 +476,17 @@ extension GlobalSearchViewController { } private func show( - threadViewModel: SessionThreadViewModel, + viewModel: ConversationInfoViewModel, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true ) async { /// If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the contact has been hidden) - if threadViewModel.threadVariant == .contact { + if viewModel.variant == .contact { _ = try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try SessionThread.upsert( db, - id: threadViewModel.threadId, - variant: threadViewModel.threadVariant, + id: viewModel.id, + variant: viewModel.variant, values: .existingOrDefault, using: dependencies ) @@ -459,20 +494,19 @@ extension GlobalSearchViewController { } /// Need to fetch the "full" data for the conversation screen - let maybeThreadViewModel: SessionThreadViewModel? = try? await ConversationViewModel.fetchThreadViewModel( - threadId: threadViewModel.threadId, - variant: threadViewModel.threadVariant, + let maybeThreadInfo: ConversationInfoViewModel? = try? await ConversationViewModel.fetchConversationInfo( + threadId: viewModel.id, using: dependencies ) - guard let finalThreadViewModel: SessionThreadViewModel = maybeThreadViewModel else { - Log.error("Failed to present \(threadViewModel.threadVariant) conversation \(threadViewModel.threadId) due to failure to fetch threadViewModel") + guard let finalThreadInfo: ConversationInfoViewModel = maybeThreadInfo else { + Log.error("Failed to present \(viewModel.variant) conversation \(viewModel.id) due to failure to fetch viewModel") return } await MainActor.run { let viewController: ConversationVC = ConversationVC( - threadViewModel: finalThreadViewModel, + threadInfo: finalThreadInfo, focusedInteractionInfo: focusedInteractionInfo, using: dependencies ) @@ -586,3 +620,76 @@ extension GlobalSearchViewController { } } } + +// MARK: - Convenience + +private extension GlobalSearch { + static func processDefaultSearchResults( + results: [GlobalSearch.ConversationSearchResult], + cache: ConversationDataCache, + using dependencies: Dependencies + ) -> GlobalSearchViewController.SearchResultData { + let nonalphabeticNameTitle: String = "#" // stringlint:ignore + let contacts: [ConversationInfoViewModel] = ConversationDataHelper.processDefaultContacts( + cache: cache, + contactIds: results.map { $0.id }, + userSessionId: dependencies[cache: .general].sessionId, + using: dependencies + ) + + return GlobalSearchViewController.SearchResultData( + state: .defaultContacts, + data: contacts + .sorted { lhs, rhs in lhs.displayName.deformatted().lowercased() < rhs.displayName.deformatted().lowercased() } + .filter { $0.isMessageRequest == false } /// Exclude message requests from the default contacts + .reduce(into: [String: GlobalSearchViewController.SectionModel]()) { result, next in + guard !next.isNoteToSelf else { + result[""] = GlobalSearchViewController.SectionModel( + model: .groupedContacts(title: ""), + elements: [next] + ) + return + } + + let displayName = NSMutableString(string: next.displayName.deformatted()) + CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) + CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) + + let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") + let section: String = (initialCharacter.capitalized.isSingleAlphabet ? + initialCharacter.capitalized : + nonalphabeticNameTitle + ) + + if result[section] == nil { + result[section] = GlobalSearchViewController.SectionModel( + model: .groupedContacts(title: section), + elements: [] + ) + } + result[section]?.elements.append(next) + } + .values + .sorted { sectionModel0, sectionModel1 in + let title0: String = { + switch sectionModel0.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + let title1: String = { + switch sectionModel1.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + + if ![title0, title1].contains(nonalphabeticNameTitle) { + return title0 < title1 + } + + return title1 == nonalphabeticNameTitle + } + ) + } +} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 92afb38965..2aff904a83 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -578,19 +578,19 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi switch section.model { case .messageRequests: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath) cell.accessibilityIdentifier = "Message requests banner" cell.isAccessibilityElement = true - cell.update(with: Int(threadViewModel.threadUnreadCount ?? 0)) + cell.update(with: threadInfo.unreadCount) return cell case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) - cell.update(with: threadViewModel, using: viewModel.dependencies) + cell.update(with: threadInfo, using: viewModel.dependencies) cell.accessibilityIdentifier = "Conversation list item" - cell.accessibilityLabel = threadViewModel.displayName + cell.accessibilityLabel = threadInfo.displayName.deformatted() return cell default: preconditionFailure("Other sections should have no content") @@ -648,9 +648,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi self.navigationController?.pushViewController(viewController, animated: true) case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] let viewController: ConversationVC = ConversationVC( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: viewModel.dependencies ) @@ -674,7 +674,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: HomeViewModel.SectionModel = sections[indexPath.section] - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] switch section.model { case .threads: @@ -682,11 +682,11 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // the 'Note to Self' conversation also doesn't support 'mark as unread' so don't // provide it there either guard - threadViewModel.threadVariant != .legacyGroup && - threadViewModel.threadId != threadViewModel.currentUserSessionId && ( - threadViewModel.threadVariant != .contact || - (try? SessionId(from: section.elements[indexPath.row].threadId))?.prefix == .standard - ) + threadInfo.variant != .legacyGroup && + threadInfo.id != threadInfo.userSessionId.hexString && ( + threadInfo.variant != .contact || + (try? SessionId(from: section.elements[indexPath.row].id))?.prefix == .standard + ) else { return nil } return UIContextualAction.configuration( @@ -695,7 +695,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .leading, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies @@ -708,7 +708,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: HomeViewModel.SectionModel = sections[indexPath.section] - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row] switch section.model { case .messageRequests: @@ -718,7 +718,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies @@ -726,13 +726,13 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi ) case .threads: - let sessionIdPrefix: SessionId.Prefix? = try? SessionId.Prefix(from: threadViewModel.threadId) + let sessionIdPrefix: SessionId.Prefix? = try? SessionId.Prefix(from: threadInfo.id) // Cannot properly sync outgoing blinded message requests so only provide valid options let shouldHavePinAction: Bool = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { // Only allow unpin for legacy groups - case .legacyGroup: return threadViewModel.threadPinnedPriority > 0 + case .legacyGroup: return (threadInfo.pinnedPriority > 0) default: return ( @@ -742,23 +742,22 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } }() let shouldHaveMuteAction: Bool = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: return ( - !threadViewModel.threadIsNoteToSelf && + !threadInfo.isNoteToSelf && sessionIdPrefix != .blinded15 && sessionIdPrefix != .blinded25 ) - case .group: return (threadViewModel.currentUserIsClosedGroupMember == true) - + case .group: return (threadInfo.groupInfo?.currentUserRole != nil) case .legacyGroup: return false case .community: return true } }() let destructiveAction: UIContextualAction.SwipeAction = { - switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember, threadViewModel.currentUserIsClosedGroupAdmin) { - case (.contact, true, _, _): return .hide - case (.group, _, true, false), (.community, _, _, _): return .leave + switch (threadInfo.variant, threadInfo.isNoteToSelf, threadInfo.groupInfo?.currentUserRole) { + case (.contact, true, _): return .hide + case (.group, _, .standard), (.community, _, _): return .leave default: return .delete } }() @@ -773,7 +772,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: self, navigatableStateHolder: viewModel, using: viewModel.dependencies diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 7e2cddc54c..343f590787 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -22,7 +22,7 @@ public extension Log.Category { public class HomeViewModel: NavigatableStateHolder { public let navigatableState: NavigatableState = NavigatableState() - public typealias SectionModel = ArraySection + public typealias SectionModel = ArraySection // MARK: - Section @@ -102,9 +102,11 @@ public class HomeViewModel: NavigatableStateHolder { let showViewedSeedBanner: Bool let hasHiddenMessageRequests: Bool let unreadMessageRequestThreadCount: Int - let loadedPageInfo: PagedData.LoadedInfo - let itemCache: [String: SessionThreadViewModel] - let profileCache: [String: Profile] + + let loadedPageInfo: PagedData.LoadedInfo + let dataCache: ConversationDataCache + let itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] + let appReviewPromptState: AppReviewPromptState? let pendingAppReviewPromptState: AppReviewPromptState? let appWasInstalledPriorToAppReviewRelease: Bool @@ -124,6 +126,8 @@ public class HomeViewModel: NavigatableStateHolder { .messageRequestDeleted, .messageRequestMessageRead, .messageRequestUnreadMessageReceived, + .anyMessageCreatedInAnyConversation, + .anyContactBlockedStatusChanged, .profile(userProfile.id), .feature(.serviceNetwork), .feature(.forceOffline), @@ -131,9 +135,6 @@ public class HomeViewModel: NavigatableStateHolder { .setting(.hasSavedMessage), .setting(.hasViewedSeed), .setting(.hasHiddenMessageRequests), - .conversationCreated, - .anyMessageCreatedInAnyConversation, - .anyContactBlockedStatusChanged, .userDefault(.hasVisitedPathScreen), .userDefault(.hasPressedDonateButton), .userDefault(.hasChangedTheme), @@ -143,26 +144,7 @@ public class HomeViewModel: NavigatableStateHolder { .showDonationsCTAModal ] - itemCache.values.forEach { threadViewModel in - result.insert(contentsOf: [ - .conversationUpdated(threadViewModel.threadId), - .conversationDeleted(threadViewModel.threadId), - .messageCreated(threadId: threadViewModel.threadId), - .messageUpdated( - id: threadViewModel.interactionId, - threadId: threadViewModel.threadId - ), - .messageDeleted( - id: threadViewModel.interactionId, - threadId: threadViewModel.threadId - ), - .typingIndicator(threadViewModel.threadId) - ]) - - if let authorId: String = threadViewModel.authorId { - result.insert(.profile(authorId)) - } - } + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) return result } @@ -173,9 +155,11 @@ public class HomeViewModel: NavigatableStateHolder { appWasInstalledPriorToAppReviewRelease: Bool, showVersionSupportBanner: Bool ) -> State { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + return State( viewState: .loading, - userProfile: Profile.with(id: dependencies[cache: .general].sessionId.hexString, name: ""), + userProfile: Profile.with(id: userSessionId.hexString, name: ""), serviceNetwork: dependencies[feature: .serviceNetwork], forceOffline: dependencies[feature: .forceOffline], hasSavedThread: false, @@ -184,20 +168,25 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests: false, unreadMessageRequestThreadCount: 0, loadedPageInfo: PagedData.LoadedInfo( - record: SessionThreadViewModel.self, + record: SessionThread.self, pageSize: HomeViewModel.pageSize, - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed - /// for the query but differs from the JOINs that are actually used for performance reasons as the - /// basic logic can be simpler for where it's used - requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL, - filterSQL: SessionThreadViewModel.homeFilterSQL( - userSessionId: dependencies[cache: .general].sessionId - ), - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.homeFilterSQL(userSessionId: userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.homeOrderSQL + ), + dataCache: ConversationDataCache( + userSessionId: userSessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ), itemCache: [:], - profileCache: [:], appReviewPromptState: nil, pendingAppReviewPromptState: appReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, @@ -223,8 +212,9 @@ public class HomeViewModel: NavigatableStateHolder { var hasHiddenMessageRequests: Bool = previousState.hasHiddenMessageRequests var unreadMessageRequestThreadCount: Int = previousState.unreadMessageRequestThreadCount var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult - var itemCache: [String: SessionThreadViewModel] = previousState.itemCache - var profileCache: [String: Profile] = previousState.profileCache + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] = previousState.itemCache + var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease @@ -260,8 +250,7 @@ public class HomeViewModel: NavigatableStateHolder { userProfile = userProfile.with(displayPictureUrl: .set(to: nil)) } - // TODO: [Database Relocation] All profiles should be stored in the `profileCache` - profileCache[userProfile.id] = userProfile + dataCache.insert(userProfile) /// If we haven't hidden the message requests banner then we should include that in the initial fetch if !hasHiddenMessageRequests { @@ -273,100 +262,65 @@ public class HomeViewModel: NavigatableStateHolder { } /// If there are no events we want to process then just return the current state - guard !eventsToProcess.isEmpty else { return previousState } + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let splitEvents: [EventDataRequirement: Set] = eventsToProcess - .reduce(into: [:]) { result, next in - switch next.dataRequirement { - case .databaseQuery: result[.databaseQuery, default: []].insert(next) - case .other: result[.other, default: []].insert(next) - case .bothDatabaseQueryAndOther: - result[.databaseQuery, default: []].insert(next) - result[.other, default: []].insert(next) - } - } - let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? - .reduce(into: [:]) { result, event in - result[event.key.generic, default: []].insert(event) - } + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + let loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) + + /// Update the context + dataCache.withContext( + source: .conversationList, + requireFullRefresh: ( + isInitialQuery || + changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) + ), + requiresMessageRequestCountUpdate: changes.containsAny( + .messageRequestUnreadMessageReceived, + .messageRequestAccepted, + .messageRequestDeleted, + .messageRequestMessageRead + ) + ) - /// Handle profile events first - groupedOtherEvents?[.profile]?.forEach { event in - guard - let eventValue: ProfileEvent = event.value as? ProfileEvent, - eventValue.id == userProfile.id - else { return } - - switch eventValue.change { - case .name(let name): userProfile = userProfile.with(name: name) - case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) - case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) - case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): - let finalFeatures: SessionPro.ProfileFeatures = { - guard dependencies[feature: .sessionProEnabled] else { return .none } - - return features - .union(dependencies[feature: .proBadgeEverywhere] ? .proBadge : .none) - }() - - userProfile = userProfile.with( - proFeatures: .set(to: finalFeatures), - proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), - proGenIndexHashHex: .set(to: proGenIndexHashHex) - ) - } - - // TODO: [Database Relocation] All profiles should be stored in the `profileCache` - profileCache[eventValue.id] = userProfile - } + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) - /// Then handle database events - if !dependencies[singleton: .storage].isSuspended, let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { do { - var fetchedConversations: [SessionThreadViewModel] = [] - let idsNeedingRequery: Set = self.extractIdsNeedingRequery( - events: databaseEvents, - cache: itemCache + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies ) - let loadPageEvent: LoadPageEvent? = databaseEvents - .first(where: { $0.key.generic == .loadPage })? - .value as? LoadPageEvent - - /// Identify any inserted/deleted records - var insertedIds: Set = [] - var deletedIds: Set = [] - - databaseEvents.forEach { event in - switch (event.key.generic, event.value) { - case (GenericObservableKey(.messageRequestAccepted), let threadId as String): - insertedIds.insert(threadId) - - case (GenericObservableKey(.conversationCreated), let event as ConversationEvent): - insertedIds.insert(event.id) - - case (GenericObservableKey(.anyMessageCreatedInAnyConversation), let event as MessageEvent): - insertedIds.insert(event.threadId) - - case (.conversationDeleted, let event as ConversationEvent): - deletedIds.insert(event.id) - - case (GenericObservableKey(.anyContactBlockedStatusChanged), let event as ContactEvent): - if case .isBlocked(true) = event.change { - deletedIds.insert(event.id) - } - else if case .isBlocked(false) = event.change { - insertedIds.insert(event.id) - } - - default: break - } - } - + } + catch { + Log.warn(.homeViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { + do { try await dependencies[singleton: .storage].readAsync { db in /// Update the `unreadMessageRequestThreadCount` if needed (since multiple events need this) - if databaseEvents.contains(where: { $0.requiresMessageRequestCountUpdate }) { + if fetchRequirements.requiresMessageRequestCountUpdate { // TODO: [Database Relocation] Should be able to clean this up by getting the conversation list and filtering struct ThreadIdVariant: Decodable, Hashable, FetchableRecord { let id: String @@ -398,48 +352,27 @@ public class HomeViewModel: NavigatableStateHolder { .fetchCount(db) } - /// Update loaded page info as needed - if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { - loadResult = try loadResult.load( - db, - target: ( - loadPageEvent?.target(with: loadResult) ?? - .reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds) - ) - ) - } - - /// Fetch any records needed - fetchedConversations.append( - contentsOf: try SessionThreadViewModel - .query( - userSessionId: dependencies[cache: .general].sessionId, - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.homeOrderSQL, - ids: Array(idsNeedingRequery) + loadResult.newIds - ) - .fetchAll(db) + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies ) } - - /// Update the `itemCache` with the newly fetched values - fetchedConversations.forEach { itemCache[$0.threadId] = $0 } - - /// Remove any deleted values - deletedIds.forEach { id in itemCache.removeValue(forKey: id) } } catch { - let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } - else if let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { - Log.warn(.homeViewModel, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") + else if !changes.databaseEvents.isEmpty { + Log.warn(.homeViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } /// Then handle remaining non-database events - groupedOtherEvents?[.setting]?.forEach { event in - guard let updatedValue: Bool = event.value as? Bool else { return } - + changes.forEachEvent(.setting, as: Bool.self) { event, updatedValue in switch event.key { case .setting(.hasSavedThread): hasSavedThread = (updatedValue || hasSavedThread) case .setting(.hasSavedMessage): hasSavedMessage = (updatedValue || hasSavedMessage) @@ -448,29 +381,30 @@ public class HomeViewModel: NavigatableStateHolder { default: break } } - groupedOtherEvents?[.feature]?.forEach { event in - if event.key == .feature(.serviceNetwork), let updatedValue = event.value as? ServiceNetwork { - serviceNetwork = updatedValue - } - else if event.key == .feature(.forceOffline), let updatedValue = event.value as? Bool { - forceOffline = updatedValue - } - else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool { - showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && updatedValue - } - else if event.key == .feature(.versionDeprecationMinimum) { - showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] - } + + if let updatedValue: ServiceNetwork = changes.latest(.feature(.serviceNetwork), as: ServiceNetwork.self) { + serviceNetwork = updatedValue + } + + if let updatedValue: Bool = changes.latest(.feature(.forceOffline), as: Bool.self) { + forceOffline = updatedValue + } + + // FIXME: Should be able to consolodate these two into a single value + if let updatedValue: Bool = changes.latest(.feature(.versionDeprecationWarning), as: Bool.self) { + showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && updatedValue) + } + + if changes.latest(.feature(.versionDeprecationMinimum), as: Int.self) != nil { + showVersionSupportBanner = (isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning]) } /// Next trigger should be ignored if `didShowAppReviewPrompt` is true if dependencies[defaults: .standard, key: .didShowAppReviewPrompt] == true { pendingAppReviewPromptState = nil } else { - groupedOtherEvents?[.userDefault]?.forEach { event in - guard let value: Bool = event.value as? Bool else { return } - - switch (event.key, value, appWasInstalledPriorToAppReviewRelease) { + changes.forEachEvent(.userDefault, as: Bool.self) { event, updatedValue in + switch (event.key, updatedValue, appWasInstalledPriorToAppReviewRelease) { case (.userDefault(.hasVisitedPathScreen), true, false): pendingAppReviewPromptState = .enjoyingSession @@ -485,19 +419,30 @@ public class HomeViewModel: NavigatableStateHolder { } } - if let event: HomeViewModelEvent = events.first?.value as? HomeViewModelEvent { - pendingAppReviewPromptState = event.pendingAppReviewPromptState - appReviewPromptState = event.appReviewPromptState + if let updatedValue: HomeViewModelEvent = changes.latestGeneric(.updateScreen, as: HomeViewModelEvent.self) { + pendingAppReviewPromptState = updatedValue.pendingAppReviewPromptState + appReviewPromptState = updatedValue.appReviewPromptState } /// If this update has an event indicating we should show the donations modal then do so, the next change will result in the flag /// being reset so we don't unintentionally show it again - if groupedOtherEvents?[.showDonationsCTAModal] != nil { + if changes.contains(.showDonationsCTAModal) { showDonationsCTAModal = true } else if showDonationsCTAModal { showDonationsCTAModal = false } + + /// Regenerate the `itemCache` now that the `dataCache` is updated + itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return } + + result[id] = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } /// Generate the new state return State( @@ -514,8 +459,8 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests: hasHiddenMessageRequests, unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, loadedPageInfo: loadResult.info, + dataCache: dataCache, itemCache: itemCache, - profileCache: profileCache, appReviewPromptState: appReviewPromptState, pendingAppReviewPromptState: pendingAppReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, @@ -524,57 +469,7 @@ public class HomeViewModel: NavigatableStateHolder { ) } - private static func extractIdsNeedingRequery( - events: Set, - cache: [String: SessionThreadViewModel] - ) -> Set { - let requireFullRefresh: Bool = events.contains(where: { event in - event.key == .appLifecycle(.willEnterForeground) || - event.key == .databaseLifecycle(.resumed) - }) - - guard !requireFullRefresh else { - return Set(cache.keys) - } - - return events.reduce(into: []) { result, event in - switch (event.key.generic, event.value) { - case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id) - case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId) - - case (.messageCreated, let event as MessageEvent), - (.messageUpdated, let event as MessageEvent), - (.messageDeleted, let event as MessageEvent): - result.insert(event.threadId) - - case (.profile, let event as ProfileEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - case (.contact, let event as ContactEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - default: break - } - } - } - private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] { - let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId - return [ /// If the message request section is hidden or there are no unread message requests then hide the message request banner (state.hasHiddenMessageRequests || state.unreadMessageRequestThreadCount == 0 ? @@ -582,10 +477,8 @@ public class HomeViewModel: NavigatableStateHolder { [SectionModel( section: .messageRequests, elements: [ - SessionThreadViewModel( - threadId: SessionThreadViewModel.messageRequestsSectionId, - unreadCount: UInt(state.unreadMessageRequestThreadCount), - using: viewModel.dependencies + ConversationInfoViewModel.unreadMessageRequestsBanner( + unreadCount: state.unreadMessageRequestThreadCount ) ] )] @@ -593,38 +486,7 @@ public class HomeViewModel: NavigatableStateHolder { [ SectionModel( section: .threads, - elements: state.loadedPageInfo.currentIds - .compactMap { state.itemCache[$0] } - .map { conversation -> SessionThreadViewModel in - conversation.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - groupIsDestroyed: ( - conversation.threadVariant == .group && - viewModel.dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: conversation.threadId) - ) - } - ), - threadCanWrite: conversation.determineInitialCanWriteFlag( - using: viewModel.dependencies - ), - threadCanUpload: conversation.determineInitialCanUploadFlag( - using: viewModel.dependencies - ) - ) - } + elements: state.loadedPageInfo.currentIds.compactMap { state.itemCache[$0] } ) ], (!state.loadedPageInfo.currentIds.isEmpty && state.loadedPageInfo.hasNextPage ? @@ -881,45 +743,27 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Convenience -private enum EventDataRequirement { - case databaseQuery - case other - case bothDatabaseQueryAndOther -} - private extension ObservedEvent { - var dataRequirement: EventDataRequirement { - switch (key, key.generic) { - case (.setting(.hasHiddenMessageRequests), _): return .bothDatabaseQueryAndOther - - case (_, .profile): return .bothDatabaseQueryAndOther - case (.feature(.serviceNetwork), _): return .other - case (.feature(.forceOffline), _): return .other - case (.setting(.hasViewedSeed), _): return .other - - case (.appLifecycle(.willEnterForeground), _): return .databaseQuery - case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), - (.messageRequestDeleted, _), (.messageRequestMessageRead, _): - return .databaseQuery - case (_, .loadPage): return .databaseQuery - case (.conversationCreated, _): return .databaseQuery - case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery - case (.anyContactBlockedStatusChanged, _): return .databaseQuery - case (_, .typingIndicator): return .databaseQuery - case (_, .conversationUpdated), (_, .conversationDeleted): return .databaseQuery - case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery - default: return .other - } - } - - var requiresMessageRequestCountUpdate: Bool { - switch self.key { - case .messageRequestUnreadMessageReceived, .messageRequestAccepted, .messageRequestDeleted, - .messageRequestMessageRead: - return true + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.setting(.hasHiddenMessageRequests), _): return [.databaseQuery, .directCacheUpdate] + case (ObservableKey.feature(.serviceNetwork), _): return .directCacheUpdate + case (ObservableKey.feature(.forceOffline), _): return .directCacheUpdate + case (.setting(.hasViewedSeed), _): return .directCacheUpdate + + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), + (.messageRequestDeleted, _), (.messageRequestMessageRead, _): + return .databaseQuery + case (_, .loadPage): return .databaseQuery - default: return false - } + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index eb6f8283c8..2121052d4d 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -9,10 +9,18 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +// MARK: - Log.Category + +public extension Log.Category { + static let messageRequestsViewModel: Log.Category = .create("MessageRequestsViewModel", defaultLevel: .warn) +} + +// MARK: - MessageRequestsViewModel + class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource { - typealias TableItem = SessionThreadViewModel + typealias TableItem = ConversationInfoViewModel typealias PagedTable = SessionThread - typealias PagedDataModel = SessionThreadViewModel + typealias PagedDataModel = ConversationInfoViewModel // MARK: - Section @@ -34,7 +42,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public let dependencies: Dependencies public let state: TableDataState = TableDataState() - public let observableState: ObservableTableSourceState = ObservableTableSourceState() + public let observableState: ObservableTableSourceState = ObservableTableSourceState() public let navigatableState: NavigatableState = NavigatableState() private let userSessionId: SessionId @@ -49,7 +57,18 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O self.userSessionId = dependencies[cache: .general].sessionId self.internalState = State.initialState(using: dependencies) - self.bindState() + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .milliseconds(250)) + .using(dependencies: dependencies) + .query(MessageRequestsViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) + } } // MARK: - Content @@ -61,7 +80,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public let cellType: SessionTableViewCellType = .fullConversation @available(*, deprecated, message: "No longer used now that we have updated this ViewModel to use the new ObservationBuilder mechanism") - var pagedDataObserver: PagedDatabaseObserver? = nil + var pagedDataObserver: PagedDatabaseObserver? = nil // MARK: - State @@ -73,8 +92,9 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } let viewState: ViewState - let loadedPageInfo: PagedData.LoadedInfo - let itemCache: [String: SessionThreadViewModel] + let loadedPageInfo: PagedData.LoadedInfo + let dataCache: ConversationDataCache + let itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] @MainActor public func sections(viewModel: MessageRequestsViewModel) -> [SectionModel] { MessageRequestsViewModel.sections(state: self, viewModel: viewModel) @@ -82,6 +102,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O public var observedKeys: Set { var result: Set = [ + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed), .loadPage(MessageRequestsViewModel.self), .messageRequestUnreadMessageReceived, .messageRequestAccepted, @@ -90,61 +112,40 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .anyMessageCreatedInAnyConversation ] - itemCache.values.forEach { item in - result.insert(contentsOf: [ - .conversationUpdated(item.threadId), - .conversationDeleted(item.threadId), - .messageCreated(threadId: item.threadId), - .messageUpdated( - id: item.interactionId, - threadId: item.threadId - ), - .messageDeleted( - id: item.interactionId, - threadId: item.threadId - ) - ]) - } + result.insert(contentsOf: Set(itemCache.values.flatMap { $0.observedKeys })) return result } static func initialState(using dependencies: Dependencies) -> State { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + return State( viewState: .loading, loadedPageInfo: PagedData.LoadedInfo( - record: SessionThreadViewModel.self, + record: SessionThread.self, pageSize: MessageRequestsViewModel.pageSize, - /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed - /// for the query but differs from the JOINs that are actually used for performance reasons as the - /// basic logic can be simpler for where it's used - requiredJoinSQL: SessionThreadViewModel.optimisedJoinSQL, - filterSQL: SessionThreadViewModel.messageRequestsFilterSQL( - userSessionId: dependencies[cache: .general].sessionId - ), - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.messageRequestsOrderSQL + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.messageRequestsFilterSQL(userSessionId: userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.messageRequestsOrderSQL + ), + dataCache: ConversationDataCache( + userSessionId: userSessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) ), itemCache: [:] ) } } - @MainActor private func bindState() { - observationTask = ObservationBuilder - .initialValue(self.internalState) - .debounce(for: .milliseconds(250)) - .using(dependencies: dependencies) - .query(MessageRequestsViewModel.queryState) - .assign { [weak self] updatedState in - guard let self = self else { return } - - // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism - self.internalState = updatedState - self.pendingTableDataSubject.send(updatedState.sections(viewModel: self)) - } - } - @Sendable private static func queryState( previousState: State, events: [ObservedEvent], @@ -152,7 +153,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O using dependencies: Dependencies ) async -> State { var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult - var itemCache: [String: SessionThreadViewModel] = previousState.itemCache + var dataCache: ConversationDataCache = previousState.dataCache + var itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel] = previousState.itemCache /// Store a local copy of the events so we can manipulate it based on the state changes var eventsToProcess: [ObservedEvent] = events @@ -166,153 +168,93 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } /// If there are no events we want to process then just return the current state - guard !eventsToProcess.isEmpty else { return previousState } + guard isInitialQuery || !eventsToProcess.isEmpty else { return previousState } /// Split the events between those that need database access and those that don't - let splitEvents: [Bool: [ObservedEvent]] = eventsToProcess - .grouped(by: \.requiresDatabaseQueryForMessageRequestsViewModel) + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) + let loadPageEvent: LoadPageEvent? = changes.latestGeneric(.loadPage, as: LoadPageEvent.self) - /// Handle database events first - let userSessionId: SessionId = dependencies[cache: .general].sessionId + /// Update the context + dataCache.withContext( + source: .conversationList, + requireFullRefresh: changes.containsAny( + .appLifecycle(.willEnterForeground), + .databaseLifecycle(.resumed) + ) + ) + + /// Process cache updates first + dataCache = await ConversationDataHelper.applyNonDatabaseEvents( + changes, + currentCache: dataCache, + using: dependencies + ) + + /// Then determine the fetch requirements + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: changes, + currentCache: dataCache, + itemCache: itemCache, + loadPageEvent: loadPageEvent + ) - if let databaseEvents: Set = splitEvents[true].map({ Set($0) }) { + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { do { - var fetchedConversations: [SessionThreadViewModel] = [] - let idsNeedingRequery: Set = extractIdsNeedingRequery( - events: databaseEvents, - cache: itemCache + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies ) - let loadPageEvent: LoadPageEvent? = databaseEvents - .first(where: { $0.key.generic == .loadPage })? - .value as? LoadPageEvent - - /// Identify any inserted/deleted records - var insertedIds: Set = [] - var deletedIds: Set = [] - - databaseEvents.forEach { event in - switch (event.key.generic, event.value) { - case (GenericObservableKey(.messageRequestAccepted), let threadId as String): - insertedIds.insert(threadId) - - case (GenericObservableKey(.conversationCreated), let event as ConversationEvent): - insertedIds.insert(event.id) - - case (GenericObservableKey(.anyMessageCreatedInAnyConversation), let event as MessageEvent): - insertedIds.insert(event.threadId) - - case (.conversationDeleted, let event as ConversationEvent): - deletedIds.insert(event.id) - - default: break - } - } - + } + catch { + Log.warn(.messageRequestsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + + /// Peform any database changes + if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { + do { try await dependencies[singleton: .storage].readAsync { db in - /// Update loaded page info as needed - if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { - loadResult = try loadResult.load( - db, - target: ( - loadPageEvent?.target(with: loadResult) ?? - .reloadCurrent(insertedIds: insertedIds, deletedIds: deletedIds) - ) - ) - } - - /// Fetch any records needed - fetchedConversations.append( - contentsOf: try SessionThreadViewModel - .query( - userSessionId: userSessionId, - groupSQL: SessionThreadViewModel.groupSQL, - orderSQL: SessionThreadViewModel.messageRequestsOrderSQL, - ids: Array(idsNeedingRequery) + loadResult.newIds - ) - .fetchAll(db) - ) - } - - /// Update the `itemCache` with the newly fetched values - fetchedConversations.forEach { thread in - let result: (wasKickedFromGroup: Bool, groupIsDestroyed: Bool) = { - guard thread.threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: thread.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) - } - }() - - itemCache[thread.threadId] = thread.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: result.wasKickedFromGroup, - groupIsDestroyed: result.groupIsDestroyed, - threadCanWrite: thread.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: thread.determineInitialCanUploadFlag(using: dependencies) + /// Fetch any required data from the cache + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + db, + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: loadPageEvent, + using: dependencies ) } - - /// Remove any deleted values - deletedIds.forEach { id in itemCache.removeValue(forKey: id) } } catch { - let eventList: String = databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") - Log.critical(.homeViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") + let eventList: String = changes.databaseEvents.map { $0.key.rawValue }.joined(separator: ", ") + Log.critical(.messageRequestsViewModel, "Failed to fetch state for events [\(eventList)], due to error: \(error)") } } + else if !changes.databaseEvents.isEmpty { + Log.warn(.messageRequestsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") + } + + /// Regenerate the `itemCache` now that the `dataCache` is updated + itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return } + + result[id] = ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } /// Generate the new state return State( viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), loadedPageInfo: loadResult.info, - itemCache: itemCache + dataCache: dataCache, + itemCache: itemCache, ) } - private static func extractIdsNeedingRequery( - events: Set, - cache: [String: SessionThreadViewModel] - ) -> Set { - return events.reduce(into: []) { result, event in - switch (event.key.generic, event.value) { - case (.conversationUpdated, let event as ConversationEvent): result.insert(event.id) - case (.typingIndicator, let event as TypingIndicatorEvent): result.insert(event.threadId) - - case (.messageCreated, let event as MessageEvent), - (.messageUpdated, let event as MessageEvent), - (.messageDeleted, let event as MessageEvent): - result.insert(event.threadId) - - case (.profile, let event as ProfileEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - case (.contact, let event as ContactEvent): - result.insert( - contentsOf: Set(cache.values - .filter { threadViewModel -> Bool in - threadViewModel.threadId == event.id || - threadViewModel.allProfileIds.contains(event.id) - } - .map { $0.threadId }) - ) - - default: break - } - } - } - private static func sections(state: State, viewModel: MessageRequestsViewModel) -> [SectionModel] { return [ [ @@ -320,15 +262,15 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O section: .threads, elements: state.loadedPageInfo.currentIds .compactMap { state.itemCache[$0] } - .map { conversation -> SessionCell.Info in + .map { threadInfo -> SessionCell.Info in return SessionCell.Info( - id: conversation, + id: threadInfo, accessibility: Accessibility( identifier: "Message request" ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: ConversationVC = ConversationVC( - threadViewModel: conversation, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: dependencies ) @@ -349,7 +291,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .map { [dependencies] state in // TODO: [Database Relocation] Looks like there is a bug where where the `clear all` button will only clear currently loaded message requests (so if there are more than 15 it'll only clear one page at a time) let threadInfo: [(id: String, variant: SessionThread.Variant)] = state.itemCache.values - .map { ($0.threadId, $0.threadVariant) } + .map { ($0.id, $0.variant) } return SessionButton.Info( style: .destructive, @@ -414,7 +356,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O switch section.model { case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row].id + let threadInfo: ConversationInfoViewModel = section.elements[indexPath.row].id return UIContextualAction.configuration( for: UIContextualAction.generateSwipeActions( @@ -422,7 +364,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O for: .trailing, indexPath: indexPath, tableView: tableView, - threadViewModel: threadViewModel, + threadInfo: threadInfo, viewController: viewController, navigatableStateHolder: nil, using: dependencies @@ -451,21 +393,26 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O // MARK: - Convenience private extension ObservedEvent { - var requiresDatabaseQueryForMessageRequestsViewModel: Bool { - /// Any event requires a database query - switch (key, key.generic) { - case (_, .loadPage): return true - case (.messageRequestUnreadMessageReceived, _): return true - case (.messageRequestAccepted, _): return true - case (.messageRequestDeleted, _): return true - case (.conversationCreated, _): return true - case (.anyMessageCreatedInAnyConversation, _): return true + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.messageRequestUnreadMessageReceived, _), (.messageRequestAccepted, _), + (.messageRequestDeleted, _), (.messageRequestMessageRead, _): + return .databaseQuery + case (_, .loadPage): return .databaseQuery - /// We only observe events from records we have explicitly fetched so if we get an event for one of these then we need to - /// trigger an update - case (_, .conversationUpdated), (_, .conversationDeleted): return true - case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return true - default: return false - } + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) } } + +// FIXME: Remove this when we ditch `PagedDataObservable` +extension ConversationInfoViewModel: @retroactive FetchableRecordWithRowId { + public var rowId: Int64 { -1 } + public init(row: GRDB.Row) throws { throw StorageError.objectNotFound } +} diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 7d5238572f..23c6518e85 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -13,6 +13,8 @@ struct MessageInfoScreen: View { let actions: [ContextMenuVC.Action] let messageViewModel: MessageViewModel let threadCanWrite: Bool + let openGroupServer: String? + let openGroupPublicKey: String? let onStartThread: (@MainActor () -> Void)? let isMessageFailed: Bool let isCurrentUser: Bool @@ -25,8 +27,6 @@ struct MessageInfoScreen: View { /// the state the user was in when the message was sent let shouldShowProBadge: Bool - let displayNameRetriever: DisplayNameRetriever - func ctaVariant(currentUserIsPro: Bool) -> ProCTAModal.Variant { guard let firstFeature: ProFeature = proFeatures.first, proFeatures.count > 1 else { return .generic @@ -93,8 +93,9 @@ struct MessageInfoScreen: View { actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, threadCanWrite: Bool, + openGroupServer: String?, + openGroupPublicKey: String?, onStartThread: (@MainActor () -> Void)?, - displayNameRetriever: @escaping DisplayNameRetriever, using dependencies: Dependencies ) { self.viewModel = ViewModel( @@ -102,6 +103,8 @@ struct MessageInfoScreen: View { actions: actions.filter { $0.actionType != .emoji }, // Exclude emoji actions messageViewModel: messageViewModel, threadCanWrite: threadCanWrite, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, onStartThread: onStartThread, isMessageFailed: [.failed, .failedToSync].contains(messageViewModel.state), isCurrentUser: messageViewModel.currentUserSessionIds.contains(messageViewModel.authorId), @@ -118,8 +121,7 @@ struct MessageInfoScreen: View { messageFeatures: messageViewModel.proMessageFeatures, profileFeatures: messageViewModel.proProfileFeatures ), - shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge), - displayNameRetriever: displayNameRetriever + shouldShowProBadge: messageViewModel.profile.proFeatures.contains(.proBadge) ) } @@ -138,7 +140,6 @@ struct MessageInfoScreen: View { MessageBubble( messageViewModel: viewModel.messageViewModel, attachmentOnly: false, - displayNameRetriever: viewModel.displayNameRetriever, dependencies: viewModel.dependencies ) .clipShape( @@ -251,7 +252,6 @@ struct MessageInfoScreen: View { MessageBubble( messageViewModel: viewModel.messageViewModel, attachmentOnly: true, - displayNameRetriever: viewModel.displayNameRetriever, dependencies: viewModel.dependencies ) .clipShape( @@ -605,22 +605,29 @@ struct MessageInfoScreen: View { } func showUserProfileModal() { - guard - viewModel.threadCanWrite, - let info: UserProfileModal.Info = viewModel.messageViewModel.createUserProfileModalInfo( - onStartThread: viewModel.onStartThread, - onProBadgeTapped: self.showSessionProCTAIfNeeded, - using: viewModel.dependencies - ) - else { return } + guard viewModel.threadCanWrite else { return } - let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModal( - info: info, - dataManager: viewModel.dependencies[singleton: .imageDataManager] - ) - ) - self.host.controller?.present(userProfileModal, animated: true, completion: nil) + Task.detached(priority: .userInitiated) { + guard + let info: UserProfileModal.Info = await viewModel.messageViewModel.createUserProfileModalInfo( + openGroupServer: viewModel.openGroupServer, + openGroupPublicKey: viewModel.openGroupPublicKey, + onStartThread: viewModel.onStartThread, + onProBadgeTapped: showSessionProCTAIfNeeded, + using: viewModel.dependencies + ) + else { return } + + await MainActor.run { + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: info, + dataManager: viewModel.dependencies[singleton: .imageDataManager] + ) + ) + self.host.controller?.present(userProfileModal, animated: true, completion: nil) + } + } } private func showMediaFullScreen(attachment: Attachment) { @@ -653,7 +660,6 @@ struct MessageBubble: View { let messageViewModel: MessageViewModel let attachmentOnly: Bool - let displayNameRetriever: DisplayNameRetriever let dependencies: Dependencies var bodyLabelTextColor: ThemeValue { @@ -675,8 +681,7 @@ struct MessageBubble: View { for: messageViewModel, with: maxWidth, textColor: bodyLabelTextColor, - searchText: nil, - displayNameRetriever: displayNameRetriever + searchText: nil ).height VStack( @@ -718,7 +723,7 @@ struct MessageBubble: View { .fixedSize(horizontal: false, vertical: true) .padding(.top, Self.inset) .padding(.horizontal, Self.inset) - .padding(.bottom, (messageViewModel.body?.isEmpty == false ? + .padding(.bottom, (messageViewModel.bubbleBody?.isEmpty == false ? -Values.smallSpacing : Self.inset )) @@ -728,8 +733,7 @@ struct MessageBubble: View { if let bodyText: ThemedAttributedString = VisibleMessageCell.getBodyAttributedText( for: messageViewModel, textColor: bodyLabelTextColor, - searchText: nil, - displayNameRetriever: displayNameRetriever + searchText: nil ) { AttributedLabel(bodyText, maxWidth: maxWidth) .padding(.horizontal, Self.inset) @@ -813,16 +817,18 @@ final class MessageInfoViewController: SessionHostingViewController Void)?, - displayNameRetriever: @escaping DisplayNameRetriever, using dependencies: Dependencies ) { let messageInfoView = MessageInfoScreen( actions: actions, messageViewModel: messageViewModel, threadCanWrite: threadCanWrite, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, onStartThread: onStartThread, - displayNameRetriever: displayNameRetriever, using: dependencies ) @@ -846,12 +852,34 @@ final class MessageInfoViewController: SessionHostingViewController (AuthenticationMethod, [AuthenticationMethod]) in - ( - try Authentication.with( - db, - swarmPublicKey: dependencies[cache: .general].sessionId.hexString, - using: dependencies - ), - try OpenGroup - .select(.server) - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - .map { try Authentication.with(db, server: $0, using: dependencies) } - ) + .readPublisher { db -> [AuthenticationMethod] in + try OpenGroup + .select(.server) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + .map { try Authentication.with(db, server: $0, using: dependencies) } } .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .tryFlatMap { (userAuth: AuthenticationMethod, communityAuth: [AuthenticationMethod]) -> AnyPublisher<(AuthenticationMethod, [String]), Error> in - Publishers + .tryFlatMap { communityAuth -> AnyPublisher<(AuthenticationMethod, [String]), Error> in + let userAuth: AuthenticationMethod = try Authentication.with( + swarmPublicKey: dependencies[cache: .general].sessionId.hexString, + using: dependencies + ) + + return Publishers .MergeMany( try communityAuth.compactMap { authMethod in switch authMethod.info { diff --git a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift index 9dc4ad8f72..bdb0ad89d3 100644 --- a/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift +++ b/Session/Settings/SessionProSettings/SessionProSettingsViewModel.swift @@ -210,10 +210,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } /// Split the events between those that need database access and those that don't - let changes: EventChangeset = eventsToProcess.split(by: { $0.dataRequirement }) + let changes: EventChangeset = eventsToProcess.split(by: { $0.handlingStrategy }) /// Process any general event changes - if let value = changes.latest(.currentUserProState, as: SessionPro.State.self) { + if let value = changes.latestGeneric(.currentUserProState, as: SessionPro.State.self) { proState = value } @@ -1271,10 +1271,10 @@ extension SessionProSettingsViewModel { // MARK: - Convenience private extension ObservedEvent { - var dataRequirement: EventDataRequirement { + var handlingStrategy: EventHandlingStrategy { switch (key, key.generic) { case (.anyConversationPinnedPriorityChanged, _): return .databaseQuery - default: return .other + default: return .directCacheUpdate } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index a2991b7122..8392589a7b 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -291,7 +291,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - if let value = changes.latest(.currentUserProState, as: SessionPro.State.self) { + if let value = changes.latestGeneric(.currentUserProState, as: SessionPro.State.self) { proState = value } diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 9d789c5b7a..e1dd9e85f9 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -30,7 +30,6 @@ final class ThemeMessagePreviewView: UIView { shouldExpanded: false, lastSearchText: nil, tableSize: UIScreen.main.bounds.size, - displayNameRetriever: { _, _ in nil }, using: dependencies ) cell.contentHStack.removeFromSuperview() @@ -53,7 +52,6 @@ final class ThemeMessagePreviewView: UIView { shouldExpanded: false, lastSearchText: nil, tableSize: UIScreen.main.bounds.size, - displayNameRetriever: { _, _ in nil }, using: dependencies ) cell.contentHStack.removeFromSuperview() diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 3065e50df8..b9ef894b26 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -1,15 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SessionUtilitiesKit public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell { - public static let mutePrefix: String = "\u{e067} " // stringlint:ignore public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 + private static let snippetFont: UIFont = .systemFont(ofSize: Values.smallFontSize) // MARK: - UI @@ -152,7 +153,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC private lazy var snippetLabel: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) + result.font = FullConversationCell.snippetFont result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail @@ -282,12 +283,12 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - Content // MARK: --Search Results - public func updateForDefaultContacts(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { + public func updateForDefaultContacts(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -298,25 +299,22 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = true - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + timestampLabel.text = cellViewModel.dateForDisplay bottomLabelStackView.isHidden = true - displayNameLabel.themeAttributedText = ThemedAttributedString( - string: cellViewModel.displayName, - attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge } public func updateForMessageSearchResult( - with cellViewModel: SessionThreadViewModel, + with cellViewModel: ConversationInfoViewModel, searchText: String, using dependencies: Dependencies ) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -327,45 +325,25 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = false - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + timestampLabel.text = cellViewModel.dateForDisplay bottomLabelStackView.isHidden = false - displayNameLabel.themeAttributedText = ThemedAttributedString( - string: cellViewModel.displayName, - attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) - snippetLabel.themeAttributedText = getHighlightedSnippet( - content: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: .contact), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), - using: dependencies - ), - authorName: (!(cellViewModel.currentUserSessionIds ?? []).contains(cellViewModel.authorId ?? "") ? - cellViewModel.authorName(for: .contact) : - nil - ), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: .textPrimary, - using: dependencies - ) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge + snippetLabel.themeAttributedText = cellViewModel.targetInteraction?.messageSnippet? + .formatted(baseFont: snippetLabel.font) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } public func updateForContactAndGroupSearchResult( - with cellViewModel: SessionThreadViewModel, + with cellViewModel: ConversationInfoViewModel, searchText: String, using dependencies: Dependencies ) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies @@ -376,40 +354,27 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC unreadImageView.isHidden = true hasMentionView.isHidden = true timestampLabel.isHidden = true - displayNameLabel.themeAttributedText = getHighlightedSnippet( - content: cellViewModel.displayName, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.mediumFontSize, - textColor: .textPrimary, - using: dependencies - ) - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge - switch cellViewModel.threadVariant { + switch cellViewModel.variant { case .contact, .community: bottomLabelStackView.isHidden = true case .legacyGroup, .group: - bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty - snippetLabel.themeAttributedText = getHighlightedSnippet( - content: (cellViewModel.threadMemberNames ?? ""), - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize, - textColor: .textPrimary, - using: dependencies - ) + bottomLabelStackView.isHidden = cellViewModel.memberNames.isEmpty + snippetLabel.themeAttributedText = cellViewModel.memberNames + .formatted(baseFont: snippetLabel.font) } } // MARK: --Standard - public func update(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { - let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + public func update(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { + let unreadCount: Int = cellViewModel.unreadCount let threadIsUnread: Bool = ( unreadCount > 0 || ( - cellViewModel.threadId != cellViewModel.currentUserSessionId && - cellViewModel.threadWasMarkedUnread == true + cellViewModel.id != cellViewModel.userSessionId.hexString && + cellViewModel.wasMarkedUnread ) ) let themeBackgroundColor: ThemeValue = (threadIsUnread ? @@ -420,7 +385,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) accentLineView.alpha = (unreadCount > 0 ? 1 : 0) - isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0) + isPinnedIcon.isHidden = (cellViewModel.pinnedPriority == 0) unreadCountView.isHidden = (unreadCount <= 0) unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) unreadCountLabel.text = (unreadCount <= 0 ? @@ -431,33 +396,33 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) ) hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && ( - cellViewModel.threadVariant == .legacyGroup || - cellViewModel.threadVariant == .group || - cellViewModel.threadVariant == .community + (cellViewModel.unreadMentionCount > 0) && ( + cellViewModel.variant == .legacyGroup || + cellViewModel.variant == .group || + cellViewModel.variant == .community ) ) profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies ) - displayNameLabel.text = cellViewModel.displayName - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) - timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge + timestampLabel.text = cellViewModel.dateForDisplay - if cellViewModel.threadContactIsTyping == true { + if cellViewModel.isTyping { snippetLabel.text = "" typingIndicatorView.isHidden = false typingIndicatorView.startAnimation() } else { displayNameLabel.themeTextColor = { - guard cellViewModel.interactionVariant != .infoGroupCurrentUserLeaving else { + guard cellViewModel.lastInteraction?.variant != .infoGroupCurrentUserLeaving else { return .textSecondary } @@ -465,30 +430,22 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC }() typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() - snippetLabel.themeAttributedText = getSnippet( - cellViewModel: cellViewModel, - textColor: { - switch cellViewModel.interactionVariant { - case .infoGroupCurrentUserLeaving: return .textSecondary - case .infoGroupCurrentUserErrorLeaving: return .danger - default: return .textPrimary - } - }(), - using: dependencies - ) + snippetLabel.themeAttributedText = cellViewModel.lastInteraction?.messageSnippet? + .formatted(baseFont: snippetLabel.font) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.smallFontSize) } - let stateInfo = cellViewModel.interactionState?.statusIconInfo( - variant: (cellViewModel.interactionVariant ?? .standardOutgoing), - hasBeenReadByRecipient: (cellViewModel.interactionHasBeenReadByRecipient ?? false), - hasAttachments: ((cellViewModel.interactionAttachmentCount ?? 0) > 0) + let stateInfo = cellViewModel.lastInteraction?.state.statusIconInfo( + variant: (cellViewModel.lastInteraction?.variant ?? .standardOutgoing), + hasBeenReadByRecipient: (cellViewModel.lastInteraction?.hasBeenReadByRecipient == true), + hasAttachments: (cellViewModel.lastInteraction?.hasAttachments == true) ) statusIndicatorView.image = stateInfo?.image statusIndicatorView.themeTintColor = stateInfo?.themeTintColor statusIndicatorView.isHidden = ( - cellViewModel.interactionVariant != .standardOutgoing && - cellViewModel.interactionState != .localOnly && - cellViewModel.interactionState != .deleted + cellViewModel.lastInteraction?.variant != .standardOutgoing && + cellViewModel.lastInteraction?.state != .localOnly && + cellViewModel.lastInteraction?.state != .deleted ) } @@ -504,19 +461,24 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // update might get reset (this should be rare and is a relatively minor bug so can be left in) if let isMuted: Bool = isMuted { let attrString: NSAttributedString = (self.snippetLabel.attributedText ?? NSAttributedString()) - let hasMutePrefix: Bool = attrString.string.starts(with: FullConversationCell.mutePrefix) + let hasMutePrefix: Bool = attrString.string.starts(with: NotificationsUI.mutePrefix.rawValue) switch (isMuted, hasMutePrefix) { case (true, false): - self.snippetLabel.attributedText = NSAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ .font: UIFont(name: "ElegantIcons", size: 10) as Any ] + self.snippetLabel.themeAttributedText = ThemedAttributedString( + string: NotificationsUI.mutePrefix.rawValue, + attributes: Lucide.attributes(for: .systemFont(ofSize: Values.smallFontSize)) ) .appending(attrString) case (false, true): self.snippetLabel.attributedText = attrString - .attributedSubstring(from: NSRange(location: FullConversationCell.mutePrefix.count, length: (attrString.length - FullConversationCell.mutePrefix.count))) + .attributedSubstring( + from: NSRange( + location: NotificationsUI.mutePrefix.rawValue.count, + length: (attrString.length - NotificationsUI.mutePrefix.rawValue.count) + ) + ) default: break } @@ -538,216 +500,4 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC } } } - - // MARK: - Snippet generation - - private func getSnippet( - cellViewModel: SessionThreadViewModel, - textColor: ThemeValue, - using dependencies: Dependencies - ) -> ThemedAttributedString { - guard cellViewModel.groupIsDestroyed != true else { - return ThemedAttributedString( - string: "groupDeletedMemberDescription" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - ) - } - guard cellViewModel.wasKickedFromGroup != true else { - return ThemedAttributedString( - string: "groupRemovedYou" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - ) - } - - // If we don't have an interaction then do nothing - guard cellViewModel.interactionId != nil else { return ThemedAttributedString() } - - let result = ThemedAttributedString() - - if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { - result.append(ThemedAttributedString( - string: FullConversationCell.mutePrefix, - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 10) as Any, - .themeForegroundColor: textColor - ] - )) - } - else if cellViewModel.threadOnlyNotifyForMentions == true { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")? - .withRenderingMode(.alwaysTemplate) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - - let imageString = ThemedAttributedString( - attachment: imageAttachment, - attributes: [.themeForegroundColor: textColor] - ) - result.append(imageString) - result.append(ThemedAttributedString( - string: " ", - attributes: [ - .font: UIFont(name: "ElegantIcons", size: 10) as Any, - .themeForegroundColor: textColor - ] - )) - } - - if - (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community) && - (cellViewModel.interactionVariant?.isInfoMessage == false) - { - let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) - - result.append(ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: "") - .localizedDeformatted(), - attributes: [ .themeForegroundColor: textColor ] - )) - } - - let previewText: String = { - switch cellViewModel.interactionVariant { - case .infoGroupCurrentUserErrorLeaving: - return "groupLeaveErrorFailed" - .put(key: "group_name", value: cellViewModel.displayName) - .localizedDeformatted() - - default: - return Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - threadContactDisplayName: cellViewModel.threadContactName(), - authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), - using: dependencies - ).localizedDeformatted() - } - }() - - result.append(ThemedAttributedString( - string: MentionUtilities.highlightMentionsNoAttributes( - in: previewText, - threadVariant: cellViewModel.threadVariant, - currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), - using: dependencies - ), - attributes: [ .themeForegroundColor: textColor ] - )) - - return result - } - - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - currentUserSessionIds: Set, - searchText: String, - fontSize: CGFloat, - textColor: ThemeValue, - using dependencies: Dependencies - ) -> ThemedAttributedString { - guard !content.isEmpty, content != "noteToSelf".localized() else { - if let authorName: String = authorName, !authorName.isEmpty { - return ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: content) - .localized(), - attributes: [ .themeForegroundColor: textColor ] - ) - } - - return ThemedAttributedString( - string: content, - attributes: [ .themeForegroundColor: textColor ] - ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes( - in: content, - threadVariant: .contact, - currentUserSessionIds: currentUserSessionIds, - using: dependencies - ) - let result: ThemedAttributedString = ThemedAttributedString( - string: mentionReplacedContent, - attributes: [ - .themeForegroundColor: ThemeValue.value(textColor, alpha: Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - SessionThreadViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } // stringlint:ignore - - return part.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) // stringlint:ignore - } - .forEach { part in - // Highlight all ranges of the text (Note: The search logic only finds results that start - // with the term so we use the regex below to ensure we only highlight those cases) - normalizedSnippet - .ranges( - of: (Dependencies.isRTL ? - "(\(part.lowercased()))(^|[^a-zA-Z0-9])" : // stringlint:ignore - "(^|[^a-zA-Z0-9])(\(part.lowercased()))" // stringlint:ignore - ), - options: [.regularExpression] - ) - .forEach { range in - let targetRange: Range = { - let term: String = String(normalizedSnippet[range]) - - // If the matched term doesn't actually match the "part" value then it means - // we've matched a term after a non-alphanumeric character so need to shift - // the range over by 1 - guard term.starts(with: part.lowercased()) else { - return (normalizedSnippet.index(after: range.lowerBound).. ThemedAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: ThemedAttributedString = ThemedAttributedString( - string: "messageSnippetGroup" - .put(key: "author", value: authorName) - .put(key: "message_snippet", value: "") - .localized(), - attributes: [ .themeForegroundColor: textColor ] - ) - - return authorPrefix.appending(result) - } - .defaulting(to: result) - } } diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 6f67b42863..902fe4cfdc 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -483,7 +483,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa using: viewModel.dependencies ) - case (let cell as FullConversationCell, let threadInfo as SessionCell.Info): + case (let cell as FullConversationCell, let threadInfo as SessionCell.Info): cell.accessibilityIdentifier = info.accessibility?.identifier cell.isAccessibilityElement = (info.accessibility != nil) cell.update(with: threadInfo.id, using: viewModel.dependencies) diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index c363758b12..4f22d64b64 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -16,50 +16,3 @@ public extension SessionProBadge { ) } } - -//public extension String { -// enum SessionProBadgePosition { -// case leading, trailing -// } -// -// @MainActor func addProBadge( -// at postion: SessionProBadgePosition, -// font: UIFont, -// textColor: ThemeValue = .textPrimary, -// proBadgeSize: SessionProBadge.Size, -// spacing: String = " ", -// using dependencies: Dependencies -// ) -> ThemedAttributedString { -// let proBadgeImage: UIImage = UIView.image( -// for: .themedKey(proBadgeSize.cacheKey, themeBackgroundColor: .primary), -// generator: { SessionProBadge(size: proBadgeSize) } -// ) -// -// let base: ThemedAttributedString = ThemedAttributedString() -// -// switch postion { -// case .leading: -// base.append( -// ThemedAttributedString( -// image: proBadgeImage, -// accessibilityLabel: SessionProBadge.accessibilityLabel, -// font: font -// ) -// ) -// base.append(ThemedAttributedString(string: spacing)) -// base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) -// case .trailing: -// base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) -// base.append(ThemedAttributedString(string: spacing)) -// base.append( -// ThemedAttributedString( -// image: proBadgeImage, -// accessibilityLabel: SessionProBadge.accessibilityLabel, -// font: font -// ) -// ) -// } -// -// return base -// } -//} diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift deleted file mode 100644 index 5f5fab7960..0000000000 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import GRDB -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit - -public extension MentionUtilities { - static func highlightMentionsNoAttributes( - in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionIds: Set, - using dependencies: Dependencies - ) -> String { - return MentionUtilities.highlightMentionsNoAttributes( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: Profile.defaultDisplayNameRetriever( - threadVariant: threadVariant, - using: dependencies - ) - ) - } -} diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 15854ea8dc..5be25f67a5 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -47,7 +47,7 @@ public extension UIContextualAction { for side: UIContextualAction.Side, indexPath: IndexPath, tableView: UITableView, - threadViewModel: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, viewController: UIViewController?, navigatableStateHolder: NavigatableStateHolder?, using dependencies: Dependencies @@ -80,8 +80,8 @@ public extension UIContextualAction { case .toggleReadStatus: let isUnread: Bool = ( - threadViewModel.threadWasMarkedUnread == true || - (threadViewModel.threadUnreadCount ?? 0) > 0 + threadInfo.wasMarkedUnread || + threadInfo.unreadCount > 0 ) return UIContextualAction( @@ -105,14 +105,14 @@ public extension UIContextualAction { Task.detached(priority: .userInitiated) { try await Task.sleep(for: unswipeAnimationDelay) switch isUnread { - case true: try? await threadViewModel.markAsRead( + case true: try? await threadInfo.markAsRead( target: .threadAndInteractions( - interactionsBeforeInclusive: threadViewModel.interactionId + interactionsBeforeInclusive: threadInfo.lastInteraction?.id ), using: dependencies ) - case false: try? await threadViewModel.markAsUnread(using: dependencies) + case false: try? await threadInfo.markAsUnread(using: dependencies) } } @@ -145,8 +145,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -174,48 +174,48 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { _, _, completionHandler in - switch threadViewModel.threadId { - case SessionThreadViewModel.messageRequestsSectionId: - dependencies.setAsync(.hasHiddenMessageRequests, true) - completionHandler(true) - - default: - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "noteToSelfHide".localized(), - body: .attributedText( - "hideNoteToSelfDescription" - .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - ), - confirmTitle: "hide".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { _ in - dependencies[singleton: .storage].writeAsync { db in - try SessionThread.deleteOrLeave( - db, - type: .hideContactConversation, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - using: dependencies - ) - } - - completionHandler(true) - }, - afterClosed: { completionHandler(false) } - ) - ) - - viewController?.present(confirmationModal, animated: true, completion: nil) + guard !threadInfo.isMessageRequestsSection else { + dependencies.setAsync(.hasHiddenMessageRequests, true) + completionHandler(true) + return } + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "noteToSelfHide".localized(), + body: .attributedText( + "hideNoteToSelfDescription" + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ), + confirmTitle: "hide".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + dependencies[singleton: .storage].writeAsync { db in + try SessionThread.deleteOrLeave( + db, + type: .hideContactConversation, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + using: dependencies + ) + } + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) } // MARK: -- pin case .pin: - let isCurrentlyPinned: Bool = (threadViewModel.threadPinnedPriority > 0) + let isCurrentlyPinned: Bool = (threadInfo.pinnedPriority > 0) + return UIContextualAction( title: (isCurrentlyPinned ? "pinUnpin".localized() : "pin".localized()), icon: (isCurrentlyPinned ? UIImage(systemName: "pin.slash") : UIImage(systemName: "pin")), @@ -264,7 +264,7 @@ public extension UIContextualAction { DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { dependencies[singleton: .storage].writeAsync { db in try SessionThread - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .updateAllAndConfig( db, SessionThread.Columns.pinnedPriority @@ -279,18 +279,18 @@ public extension UIContextualAction { case .mute: return UIContextualAction( - title: (threadViewModel.threadMutedUntilTimestamp == nil ? + title: (threadInfo.mutedUntilTimestamp == nil ? "notificationsMute".localized() : "notificationsMuteUnmute".localized() ), - icon: (threadViewModel.threadMutedUntilTimestamp == nil ? + icon: (threadInfo.mutedUntilTimestamp == nil ? UIImage(systemName: "speaker.slash") : UIImage(systemName: "speaker") ), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility( - identifier: (threadViewModel.threadMutedUntilTimestamp == nil ? "Mute button" : "Unmute button") + identifier: (threadInfo.mutedUntilTimestamp == nil ? "Mute button" : "Unmute button") ), side: side, actionIndex: targetIndex, @@ -299,7 +299,7 @@ public extension UIContextualAction { ) { _, _, completionHandler in (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? .optimisticUpdate( - isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil) + isMuted: !(threadInfo.mutedUntilTimestamp != nil) ) completionHandler(true) @@ -307,7 +307,7 @@ public extension UIContextualAction { DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { dependencies[singleton: .storage].writeAsync { db in let currentValue: TimeInterval? = try SessionThread - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .select(.mutedUntilTimestamp) .asRequest(of: TimeInterval.self) .fetchOne(db) @@ -317,7 +317,7 @@ public extension UIContextualAction { ) try SessionThread - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .updateAll( db, SessionThread.Columns.mutedUntilTimestamp.set(to: newValue) @@ -325,7 +325,8 @@ public extension UIContextualAction { if currentValue != newValue { db.addConversationEvent( - id: threadViewModel.threadId, + id: threadInfo.id, + variant: threadInfo.variant, type: .updated(.mutedUntilTimestamp(newValue)) ) } @@ -340,16 +341,16 @@ public extension UIContextualAction { guard let profileInfo: (id: String, profile: Profile?) = dependencies[singleton: .storage] .read({ db in - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: return ( - threadViewModel.threadId, - try Profile.fetchOne(db, id: threadViewModel.threadId) + threadInfo.id, + try Profile.fetchOne(db, id: threadInfo.id) ) case .group: let firstAdmin: GroupMember? = try GroupMember - .filter(GroupMember.Columns.groupId == threadViewModel.threadId) + .filter(GroupMember.Columns.groupId == threadInfo.id) .filter(GroupMember.Columns.role == GroupMember.Role.admin) .fetchOne(db) @@ -367,7 +368,7 @@ public extension UIContextualAction { else { return nil } return UIContextualAction( - title: (threadViewModel.threadIsBlocked == true ? + title: (threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), @@ -380,10 +381,10 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { [weak viewController] _, _, completionHandler in - let threadIsBlocked: Bool = (threadViewModel.threadIsBlocked == true) + let threadIsBlocked: Bool = threadInfo.isBlocked let threadIsContactMessageRequest: Bool = ( - threadViewModel.threadVariant == .contact && - threadViewModel.threadIsMessageRequest == true + threadInfo.variant == .contact && + threadInfo.isMessageRequest ) let contactChanges: [ConfigColumnAssignment] = [ Contact.Columns.isBlocked.set(to: !threadIsBlocked), @@ -398,7 +399,7 @@ public extension UIContextualAction { [.isApproved(false), .didApproveMe(true)] ) let nameToUse: String = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .group: return Profile.displayName( id: profileInfo.id, @@ -406,7 +407,7 @@ public extension UIContextualAction { nickname: profileInfo.profile?.nickname ) - default: return threadViewModel.displayName + default: return threadInfo.displayName.deformatted() } }() @@ -443,17 +444,17 @@ public extension UIContextualAction { dependencies[singleton: .storage] .writePublisher { db in // Create the contact if it doesn't exist - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .contact: try Contact .fetchOrCreate( db, - id: threadViewModel.threadId, + id: threadInfo.id, using: dependencies ) .upsert(db) try Contact - .filter(id: threadViewModel.threadId) + .filter(id: threadInfo.id) .updateAllAndConfig( db, contactChanges, @@ -461,7 +462,7 @@ public extension UIContextualAction { ) contactChangeEvents.forEach { change in db.addContactEvent( - id: threadViewModel.threadId, + id: threadInfo.id, change: change ) } @@ -492,12 +493,12 @@ public extension UIContextualAction { } // Blocked message requests should be deleted - if threadViewModel.threadIsMessageRequest == true { + if threadInfo.isMessageRequest { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -528,7 +529,7 @@ public extension UIContextualAction { tableView: tableView ) { [weak viewController] _, _, completionHandler in let confirmationModalTitle: String = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .legacyGroup, .group: return "groupLeave".localized() @@ -537,20 +538,22 @@ public extension UIContextualAction { }() let confirmationModalExplanation: ThemedAttributedString = { - switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupAdmin) { - case (.group, true): + let groupName: String = threadInfo.displayName.deformatted() + + switch (threadInfo.variant, threadInfo.groupInfo?.currentUserRole) { + case (.group, .admin): return "groupLeaveDescriptionAdmin" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - case (.legacyGroup, true): + case (.legacyGroup, .admin): return "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) default: return "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: groupName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) } }() @@ -565,7 +568,7 @@ public extension UIContextualAction { dismissOnConfirm: true, onConfirm: { _ in let deletionType: SessionThread.DeletionType = { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .legacyGroup, .group: return .leaveGroupAsync default: return .deleteCommunityAndContent } @@ -576,22 +579,24 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: deletionType, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } catch { DispatchQueue.main.async { let toastBody: String = { - switch threadViewModel.threadVariant { + let deformattedDisplayName: String = threadInfo.displayName.deformatted() + + switch threadInfo.variant { case .legacyGroup, .group: return "groupLeaveErrorFailed" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localized() default: return "communityLeaveError" - .put(key: "community_name", value: threadViewModel.displayName) + .put(key: "community_name", value: deformattedDisplayName) .localized() } }() @@ -626,17 +631,17 @@ public extension UIContextualAction { indexPath: indexPath, tableView: tableView ) { [weak viewController] _, _, completionHandler in - let isMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true) + let isMessageRequest: Bool = threadInfo.isMessageRequest let groupDestroyedOrKicked: Bool = { - guard threadViewModel.threadVariant == .group else { return false } + guard threadInfo.variant == .group else { return false } return ( - threadViewModel.wasKickedFromGroup == true || - threadViewModel.groupIsDestroyed == true + threadInfo.groupInfo?.wasKicked == true || + threadInfo.groupInfo?.isDestroyed == true ) }() let confirmationModalTitle: String = { - switch (threadViewModel.threadVariant, isMessageRequest) { + switch (threadInfo.variant, isMessageRequest) { case (_, true): return "delete".localized() case (.contact, _): return "conversationsDelete".localized() @@ -649,31 +654,28 @@ public extension UIContextualAction { }() let confirmationModalExplanation: ThemedAttributedString = { guard !isMessageRequest else { - switch threadViewModel.threadVariant { + switch threadInfo.variant { case .group: return ThemedAttributedString(string: "groupInviteDelete".localized()) default: return ThemedAttributedString(string: "messageRequestsContactDelete".localized()) } } - let threadInfo: (SessionThread.Variant, Bool) = ( - threadViewModel.threadVariant, - threadViewModel.currentUserIsClosedGroupAdmin == true - ) + let deformattedDisplayName: String = threadInfo.displayName.deformatted() - switch threadInfo { + switch (threadInfo.variant, threadInfo.groupInfo?.currentUserRole) { case (.contact, _): return "deleteConversationDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) - case (.group, true): + case (.group, .admin): return "groupDeleteDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) default: return "groupDeleteDescriptionMember" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: deformattedDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) } }() @@ -688,7 +690,7 @@ public extension UIContextualAction { dismissOnConfirm: true, onConfirm: { _ in let deletionType: SessionThread.DeletionType = { - switch (threadViewModel.threadVariant, isMessageRequest, groupDestroyedOrKicked) { + switch (threadInfo.variant, isMessageRequest, groupDestroyedOrKicked) { case (.community, _, _): return .deleteCommunityAndContent case (.group, true, _), (.group, _, true), (.legacyGroup, _, _): return .deleteGroupAndContent @@ -706,8 +708,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: deletionType, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } @@ -741,7 +743,7 @@ public extension UIContextualAction { title: "contactDelete".localized(), body: .attributedText( "contactDeleteDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadInfo.displayName.deformatted()) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), @@ -753,8 +755,8 @@ public extension UIContextualAction { try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndContact, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, using: dependencies ) } diff --git a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift index 94d807c222..8efba44c91 100644 --- a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift @@ -146,7 +146,6 @@ enum _036_GroupsRebuildChanges: Migration { /// If the group isn't in the invited state then make sure to subscribe for PNs once the migrations are done if !group.invited, let token: String = dependencies[defaults: .standard, key: .deviceToken] { let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, swarmPublicKey: group.groupSessionId, using: dependencies ) diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index 1d53aff01d..c14dba4b35 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -102,9 +102,14 @@ public extension BlindedIdLookup { ) ) { - return try lookup + lookup = try lookup .with(sessionId: sessionId) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: sessionId) + ) + return lookup } // We now need to try to match the blinded id to an existing contact, this can only be done by looping @@ -129,6 +134,10 @@ public extension BlindedIdLookup { lookup = try lookup .with(sessionId: contact.id) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: contact.id) + ) // There is an edge-case where the contact might not have their 'isApproved' flag set to true // but if we have a `BlindedIdLookup` for them and are performing the lookup from the outbox @@ -176,6 +185,10 @@ public extension BlindedIdLookup { lookup = try lookup .with(sessionId: sessionId) .upserted(db) + db.addContactEvent( + id: lookup.blindedId, + change: .unblinded(blindedId: lookup.blindedId, unblindedId: sessionId) + ) break } diff --git a/SessionMessagingKit/Database/Models/Capability.swift b/SessionMessagingKit/Database/Models/Capability.swift index c44f2c74c3..f45de32af9 100644 --- a/SessionMessagingKit/Database/Models/Capability.swift +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -16,7 +16,7 @@ public struct Capability: Codable, FetchableRecord, PersistableRecord, TableReco case isMissing } - public enum Variant: Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible { + public enum Variant: Sendable, Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible { public static var allCases: [Variant] { [.sogs, .blind, .reactions] } diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index fa9a5e8116..1abdb89e62 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -8,7 +8,7 @@ import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit -public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct ClosedGroup: Sendable, Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "closedGroup" } public typealias Columns = CodingKeys @@ -126,6 +126,27 @@ public extension ClosedGroup { case userGroup } + func with( + name: Update = .useExisting, + groupDescription: Update = .useExisting, + displayPictureUrl: Update = .useExisting, + displayPictureEncryptionKey: Update = .useExisting + ) -> ClosedGroup { + return ClosedGroup( + threadId: threadId, + name: name.or(self.name), + groupDescription: groupDescription.or(self.groupDescription), + formationTimestamp: formationTimestamp, + displayPictureUrl: displayPictureUrl.or(self.displayPictureUrl), + displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), + shouldPoll: shouldPoll, + groupIdentityPrivateKey: groupIdentityPrivateKey, + authData: authData, + invited: invited, + expired: expired + ) + } + static func approveGroupIfNeeded( _ db: ObservingDatabase, group: ClosedGroup, @@ -194,7 +215,6 @@ public extension ClosedGroup { /// Subscribe for group push notifications if let token: String = dependencies[defaults: .standard, key: .deviceToken] { let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( - db, swarmPublicKey: group.id, using: dependencies ) @@ -228,6 +248,7 @@ public extension ClosedGroup { // Remove the group from the database and unsubscribe from PNs let threadVariants: [ThreadIdVariant] = try { guard + dataToRemove.contains(.thread) || dataToRemove.contains(.pushNotifications) || dataToRemove.contains(.userGroup) || dataToRemove.contains(.libSessionState) @@ -239,6 +260,9 @@ public extension ClosedGroup { .asRequest(of: ThreadIdVariant.self) .fetchAll(db) }() + let threadVariantMap: [String: SessionThread.Variant] = threadVariants.reduce(into: [:]) { result, next in + result[next.id] = next.variant + } let messageRequestMap: [String: Bool] = dependencies.mutate(cache: .libSession) { libSession in threadVariants .map { ($0.id, libSession.isMessageRequest(threadId: $0.id, threadVariant: $0.variant)) } @@ -278,24 +302,27 @@ public extension ClosedGroup { /// Bulk unsubscripe from updated groups being removed if dataToRemove.contains(.pushNotifications) && threadVariants.contains(where: { $0.variant == .group }) { if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - try? Network.PushNotification - .preparedUnsubscribe( - token: Data(hex: token), - swarms: threadVariants - .filter { $0.variant == .group } - .compactMap { info in - let authMethod: AuthenticationMethod? = try? Authentication.with( - db, - swarmPublicKey: info.id, - using: dependencies - ) - - return authMethod.map { (SessionId(.group, hex: info.id), $0) } - }, - using: dependencies - ) - .send(using: dependencies) - .sinkUntilComplete() + let swarms: [(sessionId: SessionId, authMethod: AuthenticationMethod)] = threadVariants + .filter { $0.variant == .group } + .compactMap { info in + let authMethod: AuthenticationMethod? = try? Authentication.with( + swarmPublicKey: info.id, + using: dependencies + ) + + return authMethod.map { (SessionId(.group, hex: info.id), $0) } + } + + if !swarms.isEmpty { + try? Network.PushNotification + .preparedUnsubscribe( + token: Data(hex: token), + swarms: swarms, + using: dependencies + ) + .send(using: dependencies) + .sinkUntilComplete() + } } } } @@ -355,7 +382,11 @@ public extension ClosedGroup { .deleteAll(db) threadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + db.addConversationEvent( + id: id, + variant: (threadVariantMap[id] ?? .contact), + type: .deleted + ) /// Need an explicit event for deleting a message request to trigger a home screen update if messageRequestMap[id] == true { diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift index 4a9b70bc9b..77fa36e695 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -5,7 +5,7 @@ import GRDB import SessionUIKit import SessionUtilitiesKit -public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct GroupMember: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "groupMember" } public typealias Columns = CodingKeys @@ -17,7 +17,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis case isHidden } - public enum Role: Int, Codable, Comparable, CaseIterable, DatabaseValueConvertible { + public enum Role: Int, Sendable, Codable, Comparable, CaseIterable, DatabaseValueConvertible { case standard case zombie case moderator @@ -26,7 +26,7 @@ public struct GroupMember: Codable, Equatable, Hashable, FetchableRecord, Persis public static func < (lhs: Role, rhs: Role) -> Bool { lhs.rawValue < rhs.rawValue } } - public enum RoleStatus: Int, Codable, CaseIterable, DatabaseValueConvertible { + public enum RoleStatus: Int, Sendable, Codable, CaseIterable, DatabaseValueConvertible { case accepted case pending case failed diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 8efdee7998..a66b9628e5 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -590,7 +590,11 @@ public extension Interaction { _ = try Interaction .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - db.addConversationEvent(id: threadId, type: .updated(.unreadCount)) + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.unreadCount) + ) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -649,7 +653,11 @@ public extension Interaction { interactionInfoToMarkAsRead.forEach { info in db.addMessageEvent(id: info.id, threadId: threadId, type: .updated(.wasRead(true))) } - db.addConversationEvent(id: threadId, type: .updated(.unreadCount)) + db.addConversationEvent( + id: threadId, + variant: threadVariant, + type: .updated(.unreadCount) + ) /// Need to trigger an unread message request count update as well if dependencies.mutate(cache: .libSession, { $0.isMessageRequest(threadId: threadId, threadVariant: threadVariant) }) { @@ -1068,62 +1076,7 @@ public extension Interaction { } } - /// Use the `Interaction.previewText` method directly where possible rather than this one to avoid database queries - static func notificationPreviewText( - _ db: ObservingDatabase, - interaction: Interaction, - using dependencies: Dependencies - ) -> String { - switch interaction.variant { - case .standardIncoming, .standardOutgoing: - return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - attachmentDescriptionInfo: try? Interaction.attachmentDescription( - db, - interactionId: interaction.id - ), - attachmentCount: try? { - guard let interactionId = interaction.id else { return 0 } - - return try InteractionAttachment - .filter(InteractionAttachment.Columns.interactionId == interactionId) - .fetchCount(db) - }(), - isOpenGroupInvitation: { - guard - let request: SQLRequest = Interaction.linkPreview( - url: interaction.linkPreviewUrl, - timestampMs: interaction.timestampMs, - variants: [.openGroupInvitation] - ), - let count: Int = try? request.fetchCount(db) - else { return false } - - return (count > 0) - }(), - using: dependencies - ) - - case .infoMediaSavedNotification, .infoScreenshotNotification, .infoCall: - // Note: These should only occur in 'contact' threads so the `threadId` - // is the contact id - return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - authorDisplayName: Profile.displayName(db, id: interaction.threadId), - using: dependencies - ) - - default: return Interaction.previewText( - variant: interaction.variant, - body: interaction.body, - using: dependencies - ) - } - } - - /// This menthod generates the preview text for a given transaction + /// This function generates the preview text for a given interaction to be displayed in notification content or the conversation list static func previewText( variant: Variant, body: String?, @@ -1131,8 +1084,7 @@ public extension Interaction { authorDisplayName: String = "", attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, attachmentCount: Int? = nil, - isOpenGroupInvitation: Bool = false, - using dependencies: Dependencies + isOpenGroupInvitation: Bool = false ) -> String { switch variant { case ._legacyStandardIncomingDeleted, .standardIncomingDeleted, .standardIncomingDeletedLocally, diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 64802fa5c3..df4faf8d26 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -7,7 +7,7 @@ import GRDB import SessionNetworkingKit import SessionUtilitiesKit -public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct OpenGroup: Sendable, Codable, Equatable, Hashable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "openGroup" } public typealias Columns = CodingKeys @@ -31,7 +31,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe case displayPictureOriginalUrl } - public struct Permissions: OptionSet, Codable, DatabaseValueConvertible, Hashable { + public struct Permissions: OptionSet, Sendable, Codable, DatabaseValueConvertible, Hashable { public let rawValue: UInt16 public init(rawValue: UInt16) { @@ -69,6 +69,7 @@ public struct OpenGroup: Codable, Equatable, Hashable, Identifiable, FetchableRe static let write: Permissions = Permissions(rawValue: 1 << 1) static let upload: Permissions = Permissions(rawValue: 1 << 2) + static let noPermissions: Permissions = [] static let all: Permissions = [ .read, .write, .upload ] } @@ -230,23 +231,30 @@ public extension OpenGroup { return "\(server.lowercased()).\(roomToken)" } - func with(shouldPoll: Bool, sequenceNumber: Int64) -> OpenGroup { + func with( + name: Update = .useExisting, + roomDescription: Update = .useExisting, + shouldPoll: Update = .useExisting, + sequenceNumber: Update = .useExisting, + permissions: Update = .useExisting, + displayPictureOriginalUrl: Update = .useExisting + ) -> OpenGroup { return OpenGroup( server: server, roomToken: roomToken, publicKey: publicKey, - shouldPoll: shouldPoll, - name: name, - roomDescription: roomDescription, + shouldPoll: shouldPoll.or(self.shouldPoll), + name: name.or(self.name), + roomDescription: roomDescription.or(self.roomDescription), imageId: imageId, userCount: userCount, infoUpdates: infoUpdates, - sequenceNumber: sequenceNumber, + sequenceNumber: sequenceNumber.or(self.sequenceNumber), inboxLatestMessageId: inboxLatestMessageId, outboxLatestMessageId: outboxLatestMessageId, pollFailureCount: pollFailureCount, - permissions: permissions, - displayPictureOriginalUrl: displayPictureOriginalUrl + permissions: permissions.or(self.permissions), + displayPictureOriginalUrl: displayPictureOriginalUrl.or(self.displayPictureOriginalUrl) ) } } diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 915f512d01..f5f58077f3 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -252,71 +252,6 @@ public extension Profile { } } -// MARK: - Deprecated GRDB Interactions - -public extension Profile { - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func displayName( - id: ID, - customFallback: String? = nil, - using dependencies: Dependencies - ) -> String { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var displayName: String? - dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayName(db, id: id) }, - completion: { result in - switch result { - case .failure: break - case .success(let name): displayName = name - } - semaphore.signal() - } - ) - semaphore.wait() - return (displayName ?? (customFallback ?? id)) - } - - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func displayNameNoFallback( - id: ID, - threadVariant: SessionThread.Variant = .contact, - suppressId: Bool = false, - using dependencies: Dependencies - ) -> String? { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var displayName: String? - dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, - completion: { result in - switch result { - case .failure: break - case .success(let name): displayName = name - } - semaphore.signal() - } - ) - semaphore.wait() - return displayName - } - - @available(*, deprecated, message: "This function should be avoided as it uses a blocking database query to retrieve the result. Use an async method instead.") - static func defaultDisplayNameRetriever( - threadVariant: SessionThread.Variant = .contact, - using dependencies: Dependencies - ) -> (DisplayNameRetriever) { - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - return { sessionId, _ in - Profile.displayNameNoFallback( - id: sessionId, - threadVariant: threadVariant, - using: dependencies - ) - } - } -} - - // MARK: - Search Queries public extension Profile { diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 4852b7b42b..6342e8af8e 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -5,7 +5,8 @@ import GRDB import SessionUtilitiesKit import SessionNetworkingKit -public struct SessionThread: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, IdentifiableTableRecord { +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 @@ -111,7 +112,11 @@ public struct SessionThread: Codable, Identifiable, Equatable, Hashable, Fetchab 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, type: .created) + observingDb.addConversationEvent( + id: id, + variant: variant, + type: .created + ) } } } @@ -357,6 +362,7 @@ public extension SessionThread { /// Notify of update db.addConversationEvent( id: id, + variant: variant, type: .updated(.disappearingMessageConfiguration(config)) ) @@ -374,6 +380,7 @@ public extension SessionThread { /// Notify of update db.addConversationEvent( id: id, + variant: variant, type: .updated(.disappearingMessageConfiguration(config)) ) @@ -389,10 +396,18 @@ public extension SessionThread { if case .setTo(let value) = values.shouldBeVisible, value != result.shouldBeVisible { requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value)) finalShouldBeVisible = value - db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(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, type: (value ? .created : .deleted)) + 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) }) { @@ -403,7 +418,11 @@ public extension SessionThread { if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority { requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value)) finalPinnedPriority = value - db.addConversationEvent(id: id, type: .updated(.pinnedPriority(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.pinnedPriority(value)) + ) } if case .setTo(let value) = values.isDraft, value != result.isDraft { @@ -414,13 +433,21 @@ public extension SessionThread { if case .setTo(let value) = values.mutedUntilTimestamp, value != result.mutedUntilTimestamp { requiredChanges.append(SessionThread.Columns.mutedUntilTimestamp.set(to: value)) finalMutedUntilTimestamp = value - db.addConversationEvent(id: id, type: .updated(.mutedUntilTimestamp(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.mutedUntilTimestamp(value)) + ) } if case .setTo(let value) = values.onlyNotifyForMentions, value != result.onlyNotifyForMentions { requiredChanges.append(SessionThread.Columns.onlyNotifyForMentions.set(to: value)) finalOnlyNotifyForMentions = value - db.addConversationEvent(id: id, type: .updated(.onlyNotifyForMentions(value))) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.onlyNotifyForMentions(value)) + ) } /// If no changes were needed we can just return the existing/default thread @@ -578,6 +605,7 @@ public extension SessionThread { try SessionThread.updateVisibility( db, threadIds: threadIds, + threadVariant: threadVariant, isVisible: false, using: dependencies ) @@ -593,6 +621,7 @@ public extension SessionThread { try SessionThread.updateVisibility( db, threadIds: threadIds, + threadVariant: threadVariant, isVisible: false, using: dependencies ) @@ -617,7 +646,11 @@ public extension SessionThread { .reduce(into: [:]) { result, next in result[next.0] = next.1 } } remainingThreadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + 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 { @@ -637,6 +670,7 @@ public extension SessionThread { try SessionThread.updateVisibility( db, threadIds: threadIds, + threadVariant: threadVariant, isVisible: false, using: dependencies ) @@ -676,7 +710,11 @@ public extension SessionThread { .reduce(into: [:]) { result, next in result[next.0] = next.1 } } remainingThreadIds.forEach { id in - db.addConversationEvent(id: id, type: .deleted) + 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 { @@ -715,9 +753,32 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { + func with( + shouldBeVisible: Update = .useExisting, + messageDraft: Update = .useExisting, + mutedUntilTimestamp: Update = .useExisting, + onlyNotifyForMentions: Update = .useExisting, + markedAsUnread: Update = .useExisting, + pinnedPriority: Update = .useExisting + ) -> SessionThread { + return SessionThread( + id: id, + variant: variant, + creationDateTimestamp: 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 + ) + } + static func updateVisibility( _ db: ObservingDatabase, threadId: String, + threadVariant: SessionThread.Variant, isVisible: Bool, customPriority: Int32? = nil, additionalChanges: [ConfigColumnAssignment] = [], @@ -726,6 +787,7 @@ public extension SessionThread { try updateVisibility( db, threadIds: [threadId], + threadVariant: threadVariant, isVisible: isVisible, customPriority: customPriority, additionalChanges: additionalChanges, @@ -736,6 +798,7 @@ public extension SessionThread { static func updateVisibility( _ db: ObservingDatabase, threadIds: [String], + threadVariant: SessionThread.Variant, isVisible: Bool, customPriority: Int32? = nil, additionalChanges: [ConfigColumnAssignment] = [], @@ -778,14 +841,26 @@ public extension SessionThread { /// Emit events for any changes threadIds.forEach { id in if currentInfo[id]?.shouldBeVisible != isVisible { - db.addConversationEvent(id: id, type: .updated(.shouldBeVisible(isVisible))) + db.addConversationEvent( + id: id, + variant: threadVariant, + type: .updated(.shouldBeVisible(isVisible)) + ) /// Toggling visibility is the same as "creating"/"deleting" a conversation - db.addConversationEvent(id: id, type: (isVisible ? .created : .deleted)) + db.addConversationEvent( + id: id, + variant: threadVariant, + type: (isVisible ? .created : .deleted) + ) } if currentInfo[id]?.pinnedPriority != targetPriority { - db.addConversationEvent(id: id, type: .updated(.pinnedPriority(targetPriority))) + db.addConversationEvent( + id: id, + variant: threadVariant, + type: .updated(.pinnedPriority(targetPriority)) + ) } } } @@ -816,15 +891,15 @@ public extension SessionThread { static func displayName( threadId: String, variant: Variant, - closedGroupName: String?, - openGroupName: String?, + groupName: String?, + communityName: String?, isNoteToSelf: Bool, ignoreNickname: Bool, profile: Profile? ) -> String { switch variant { - case .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized()) - case .community: return (openGroupName ?? "communityUnknown".localized()) + case .legacyGroup, .group: return (groupName ?? "groupUnknown".localized()) + case .community: return (communityName ?? "communityUnknown".localized()) case .contact: guard !isNoteToSelf else { return "noteToSelf".localized() } guard let profile: Profile = profile else { return threadId.truncated() } @@ -845,18 +920,29 @@ public extension SessionThread { let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = openGroupCapabilityInfo else { return nil } - // Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities) - guard - openGroupCapabilityInfo.capabilities.isEmpty || - openGroupCapabilityInfo.capabilities.contains(.blind) - 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, + 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: openGroupCapabilityInfo.publicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) @@ -866,7 +952,7 @@ public extension SessionThread { return dependencies[singleton: .crypto] .generate( .blinded25KeyPair( - serverPublicKey: openGroupCapabilityInfo.publicKey, + serverPublicKey: publicKey, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index d6f8402365..ebf84a9123 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -121,7 +121,7 @@ public enum AttachmentDownloadJob: JobExecutor { let request: Network.PreparedRequest switch maybeAuthMethod { - case let authMethod as Authentication.community: + case let authMethod as Authentication.Community: request = try Network.SOGS.preparedDownload( url: downloadUrl, roomToken: authMethod.roomToken, diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index f6091b0b73..7e06331a9d 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -242,7 +242,7 @@ public extension AttachmentUploadJob { ) async throws -> (attachment: Attachment, response: FileUploadResponse) { let shouldEncrypt: Bool = { switch authMethod { - case is Authentication.community: return false + case is Authentication.Community: return false default: return true } }() @@ -305,7 +305,7 @@ public extension AttachmentUploadJob { /// Return the request and the prepared attachment switch authMethod { - case let communityAuth as Authentication.community: + case let communityAuth as Authentication.Community: request = try Network.SOGS.preparedUpload( data: preparedData, roomToken: communityAuth.roomToken, @@ -339,7 +339,7 @@ public extension AttachmentUploadJob { switch (attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { case (.some(let downloadUrl), false, _): return downloadUrl - case (_, _, let community as Authentication.community): + case (_, _, let community as Authentication.Community): return Network.SOGS.downloadUrlString( for: response.id, server: community.server, diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index f1266f72a1..7620aa326c 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -91,12 +91,14 @@ public enum ConfigurationSyncJob: JobExecutor { let additionalTransientData: AdditionalTransientData? = (job.transientData as? AdditionalTransientData) Log.info(.cat, "For \(swarmPublicKey) started with changes: \(pendingPushes.pushData.count), old hashes: \(pendingPushes.obsoleteHashes.count)") - dependencies[singleton: .storage] - .readPublisher { db -> AuthenticationMethod in - try Authentication.with(db, swarmPublicKey: swarmPublicKey, using: dependencies) - } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Network.BatchResponse), Error> in - try Network.SnodeAPI.preparedSequence( + AnyPublisher + .lazy { () -> Network.PreparedRequest in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: swarmPublicKey, + using: dependencies + ) + + return try Network.SnodeAPI.preparedSequence( requests: [] .appending(contentsOf: additionalTransientData?.beforeSequenceRequests) .appending( @@ -134,8 +136,9 @@ public enum ConfigurationSyncJob: JobExecutor { snodeRetrievalRetryCount: 0, // This job has it's own retry mechanism requestAndPathBuildTimeout: Network.defaultTimeout, using: dependencies - ).send(using: dependencies) + ) } + .flatMap { request in request.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .tryMap { (_: ResponseInfoType, response: Network.BatchResponse) -> [ConfigDump] in diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 2c5697ff89..c85045a927 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -57,7 +57,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { return try Network.SOGS.preparedDownload( fileId: fileId, roomToken: roomToken, - authMethod: Authentication.community(info: info), + authMethod: Authentication.Community(info: info), skipAuthentication: skipAuthentication, using: dependencies ) @@ -264,7 +264,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addProfileEvent(id: id, change: .displayPictureUrl(url)) - db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + db.addConversationEvent( + id: id, + variant: .contact, + type: .updated(.displayPictureUrl(url)) + ) case .group(let id, let url, let encryptionKey): _ = try? ClosedGroup @@ -275,7 +279,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { ClosedGroup.Columns.displayPictureEncryptionKey.set(to: encryptionKey), using: dependencies ) - db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + db.addConversationEvent( + id: id, + variant: .group, + type: .updated(.displayPictureUrl(url)) + ) case .community(_, let roomToken, let server, _): _ = try? OpenGroup @@ -287,6 +295,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addConversationEvent( id: OpenGroup.idFor(roomToken: roomToken, server: server), + variant: .community, type: .updated(.displayPictureUrl(downloadUrl)) ) } diff --git a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift index ef3cdb9d7a..1d43425827 100644 --- a/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift +++ b/SessionMessagingKit/Jobs/ExpirationUpdateJob.swift @@ -24,15 +24,14 @@ public enum ExpirationUpdateJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db in + AnyPublisher + .lazy { try Network.SnodeAPI .preparedUpdateExpiry( serverHashes: details.serverHashes, updatedExpiryMs: details.expirationTimestampMs, shortenOnly: true, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), diff --git a/SessionMessagingKit/Jobs/GetExpirationJob.swift b/SessionMessagingKit/Jobs/GetExpirationJob.swift index 00be1730b7..567db05567 100644 --- a/SessionMessagingKit/Jobs/GetExpirationJob.swift +++ b/SessionMessagingKit/Jobs/GetExpirationJob.swift @@ -37,12 +37,11 @@ public enum GetExpirationJob: JobExecutor { return success(job, false) } - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + AnyPublisher + .lazy { try Network.SnodeAPI.preparedGetExpiries( of: expirationInfo.map { $0.key }, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), diff --git a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift index 4085974d53..f28969b605 100644 --- a/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupInviteMemberJob.swift @@ -46,7 +46,7 @@ public enum GroupInviteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> (AuthenticationMethod, AuthenticationMethod) in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -56,18 +56,18 @@ public enum GroupInviteMemberJob: JobExecutor { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) - - return ( - try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), - try Authentication.with( - db, - swarmPublicKey: details.memberSessionIdHexString, - using: dependencies - ) - ) } - .tryFlatMap { groupAuthMethod, memberAuthMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let groupAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + let memberAuthMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: try GroupUpdateInviteMessage( inviteeSessionIdHexString: details.memberSessionIdHexString, groupSessionId: SessionId(.group, hex: threadId), diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index bee66e1315..d5cbd2e6b5 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -69,7 +69,7 @@ public enum GroupLeavingJob: JobExecutor { switch (finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) { case (.leave, _, false): let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: threadId) - let authMethod: AuthenticationMethod = try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + let authMethod: AuthenticationMethod = try Authentication.with(swarmPublicKey: threadId, using: dependencies) return .sendLeaveMessage(authMethod, disappearingConfig) diff --git a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift index b63bc7dc60..1a916ece9f 100644 --- a/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift +++ b/SessionMessagingKit/Jobs/GroupPromoteMemberJob.swift @@ -61,7 +61,7 @@ public enum GroupPromoteMemberJob: JobExecutor { /// Perform the actual message sending dependencies[singleton: .storage] - .writePublisher { db -> AuthenticationMethod in + .writePublisher { db in _ = try? GroupMember .filter(GroupMember.Columns.groupId == threadId) .filter(GroupMember.Columns.profileId == details.memberSessionIdHexString) @@ -72,10 +72,15 @@ public enum GroupPromoteMemberJob: JobExecutor { using: dependencies ) - return try Authentication.with(db, swarmPublicKey: details.memberSessionIdHexString, using: dependencies) + return try Authentication.with(swarmPublicKey: details.memberSessionIdHexString, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + .tryFlatMap { _ -> AnyPublisher<(ResponseInfoType, Message), Error> in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: details.memberSessionIdHexString, + using: dependencies + ) + + return try MessageSender.preparedSend( message: message, to: .contact(publicKey: details.memberSessionIdHexString), namespace: .default, diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index 9196301bfc..b31db68374 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -33,10 +33,14 @@ public enum SendReadReceiptsJob: JobExecutor { return success(job, true) } - dependencies[singleton: .storage] - .readPublisher { db in try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) } - .tryFlatMap { authMethod -> AnyPublisher<(ResponseInfoType, Message), Error> in - try MessageSender.preparedSend( + AnyPublisher + .lazy { () -> AnyPublisher<(ResponseInfoType, Message), Error> in + let authMethod: AuthenticationMethod = try Authentication.with( + swarmPublicKey: threadId, + using: dependencies + ) + + return try MessageSender.preparedSend( message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index 9905ebedce..db4dc222f1 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -63,7 +63,11 @@ internal extension LibSessionCacheType { SessionThread.Columns.markedAsUnread.set(to: markedAsUnread), using: dependencies ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(markedAsUnread))) + db.addConversationEvent( + id: threadId, + variant: threadInfo.variant, + type: .updated(.markedAsUnread(markedAsUnread)) + ) } // If the device has a more recent read interaction then return the info so we can diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index dcff48ec07..6d45b282c3 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -132,12 +132,17 @@ internal extension LibSessionCacheType { // Emit events if existingGroup?.name != groupName { - db.addConversationEvent(id: groupSessionId.hexString, type: .updated(.displayName(groupName))) + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.displayName(groupName)) + ) } if existingGroup?.groupDescription == groupDesc { db.addConversationEvent( id: groupSessionId.hexString, + variant: .group, type: .updated(.description(groupDesc)) ) } @@ -283,7 +288,6 @@ internal extension LibSessionCacheType { // send a fire-and-forget API call to delete the messages from the swarm if isAdmin && !messageHashesToDelete.isEmpty { (try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies )).map { authMethod in @@ -355,12 +359,17 @@ internal extension LibSession { groups_info_set_description(conf, &cGroupDesc) if currentGroupName != group.name { - db.addConversationEvent(id: group.threadId, type: .updated(.displayName(group.name))) + db.addConversationEvent( + id: group.threadId, + variant: .group, + type: .updated(.displayName(group.name)) + ) } if currentGroupDesc != group.groupDescription { db.addConversationEvent( id: group.threadId, + variant: .group, type: .updated(.description(group.groupDescription)) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 2a38ae77a7..f3681fe217 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -139,6 +139,7 @@ internal extension LibSession { db.addEvent( ConversationEvent( id: thread.id, + variant: thread.variant, change: .pinnedPriority( thread.pinnedPriority .map { Int32($0 == 0 ? LibSession.visiblePriority : max($0, 1)) } @@ -509,8 +510,8 @@ public extension LibSession.Cache { return SessionThread.displayName( threadId: threadId, variant: threadVariant, - closedGroupName: finalClosedGroupName, - openGroupName: finalOpenGroupName, + groupName: finalClosedGroupName, + communityName: finalOpenGroupName, isNoteToSelf: (threadId == userSessionId.hexString), ignoreNickname: false, profile: finalProfile @@ -1014,12 +1015,12 @@ public protocol LibSessionRespondingViewController { var isConversationList: Bool { get } func isConversation(in threadIds: [String]) -> Bool - func forceRefreshIfNeeded() + @MainActor func forceRefreshIfNeeded() } public extension LibSessionRespondingViewController { var isConversationList: Bool { false } func isConversation(in threadIds: [String]) -> Bool { return false } - func forceRefreshIfNeeded() {} + @MainActor func forceRefreshIfNeeded() {} } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index 63fb76b77a..d2514f0458 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -39,7 +39,8 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleUserGroupsUpdate( _ db: ObservingDatabase, - in config: LibSession.Config? + in config: LibSession.Config?, + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .userGroups(let conf) = config else { @@ -76,6 +77,7 @@ internal extension LibSessionCacheType { roomToken: community.roomToken, server: community.server, publicKey: community.publicKey, + joinedAt: community.joinedAt, forceVisible: true ) @@ -111,6 +113,7 @@ internal extension LibSessionCacheType { if existingInfo.pinnedPriority != community.priority { db.addConversationEvent( id: community.threadId, + variant: .community, type: .updated(.pinnedPriority(community.priority)) ) } @@ -211,7 +214,11 @@ internal extension LibSessionCacheType { } if existingLegacyGroups[group.id]?.name != name { - db.addConversationEvent(id: group.id, type: .updated(.displayName(name))) + db.addConversationEvent( + id: group.id, + variant: .legacyGroup, + type: .updated(.displayName(name)) + ) } // Update the members @@ -306,6 +313,7 @@ internal extension LibSessionCacheType { db.addConversationEvent( id: group.id, + variant: .legacyGroup, type: .updated(.pinnedPriority(group.priority ?? LibSession.hiddenPriority)) ) } @@ -416,10 +424,15 @@ internal extension LibSessionCacheType { if existingInfo.pinnedPriority != group.priority { db.addConversationEvent( id: group.groupSessionId, + variant: .group, type: .updated(.pinnedPriority(group.priority)) ) } } + + if oldState[.groupInfo(groupId: group.groupSessionId)] as? LibSession.GroupInfo != group { + db.addEvent(group, forKey: .groupInfo(groupId: group.groupSessionId)) + } } // Remove any groups which are no longer in the config @@ -1042,6 +1055,47 @@ public extension LibSession.Cache { return ugroups_group_is_destroyed(&userGroup) } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + var group: ugroups_group_info = ugroups_group_info() + + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &group, &cGroupId) + else { return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) } + + return GroupAuthData( + groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)), + authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)) + ) + } + + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { return [] } + + + return groupIds.map { groupId -> LibSession.GroupInfo? in + var group: ugroups_group_info = ugroups_group_info() + + guard + var cGroupId: [CChar] = groupId.cString(using: .utf8), + user_groups_get_group(conf, &group, &cGroupId) + else { return nil } + + return LibSession.GroupInfo( + groupSessionId: group.get(\.id), + groupIdentityPrivateKey: (!group.have_secretkey ? nil : group.get(\.secretkey, nullIfEmpty: true)), + name: group.get(\.name), + authData: (!group.have_auth_data ? nil : group.get(\.auth_data, nullIfEmpty: true)), + priority: group.priority, + joinedAt: TimeInterval(group.joined_at), + invited: group.invited, + wasKickedFromGroup: ugroups_group_is_kicked(&group), + wasGroupDestroyed: ugroups_group_is_destroyed(&group) + ) + } + } } // MARK: - Convenience @@ -1079,7 +1133,8 @@ public extension LibSession { server: server, roomToken: roomToken, publicKey: community.getHex(\.pubkey), - priority: community.priority + priority: community.priority, + joinedAt: TimeInterval(community.joined_at) ) ) } @@ -1148,6 +1203,7 @@ public extension LibSession { let roomToken: String let publicKey: String let priority: Int32 + let joinedAt: TimeInterval } } @@ -1188,7 +1244,7 @@ public extension LibSession { // MARK: - GroupInfo public extension LibSession { - struct GroupInfo { + struct GroupInfo: Sendable, Equatable, Hashable { let groupSessionId: String let groupIdentityPrivateKey: Data? let name: String @@ -1254,6 +1310,6 @@ public extension LibSession { // MARK: - C Conformance -extension ugroups_community_info: CAccessible & CMutable {} -extension ugroups_legacy_group_info: CAccessible & CMutable {} -extension ugroups_group_info: CAccessible & CMutable {} +extension ugroups_community_info: @retroactive CAccessible & CMutable {} +extension ugroups_legacy_group_info: @retroactive CAccessible & CMutable {} +extension ugroups_group_info: @retroactive CAccessible & CMutable {} diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index c91398f361..6eec91b388 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -778,6 +778,16 @@ public extension LibSession { result[.profile(next.key)] = next.value.profile } + case .userGroups(let conf): + let extractedUserGroups: ExtractedUserGroups = try extractUserGroups(from: conf, using: dependencies) + var userGroupEvents: [ObservableKey: Any] = [:] + + extractedUserGroups.groups.forEach { info in + userGroupEvents[.groupInfo(groupId: info.groupSessionId)] = info + } + + result[variant] = userGroupEvents + default: break } } @@ -860,7 +870,8 @@ public extension LibSession { case .userGroups: try handleUserGroupsUpdate( db, - in: config + in: config, + oldState: oldState ) case .groupInfo: @@ -1137,8 +1148,11 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func wasKickedFromGroup(groupSessionId: SessionId) -> Bool func groupName(groupSessionId: SessionId) -> String? func groupIsDestroyed(groupSessionId: SessionId) -> Bool + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? + + func authData(groupSessionId: SessionId) -> GroupAuthData } public extension LibSessionCacheType { @@ -1416,8 +1430,13 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { return false } func groupName(groupSessionId: SessionId) -> String? { return nil } func groupIsDestroyed(groupSessionId: SessionId) -> Bool { return false } + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { return [] } func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return GroupAuthData(groupIdentityPrivateKey: nil, authData: nil) + } } // MARK: - Convenience diff --git a/SessionMessagingKit/LibSession/Types/GroupAuthData.swift b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift new file mode 100644 index 0000000000..cb0dd8bf24 --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/GroupAuthData.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct GroupAuthData: Codable { + let groupIdentityPrivateKey: Data? + let authData: Data? +} diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index 67c73cc876..c59174619b 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -119,10 +119,7 @@ public actor CommunityManager: CommunityManagerType { let members: [String: [GroupMember]] = data.members.grouped(by: \.groupId) _servers = rooms.reduce(into: [:]) { result, next in - guard - let threadId: String = next.value.first?.threadId, - let publicKey: String = next.value.first?.publicKey - else { return } + guard let publicKey: String = next.value.first?.publicKey else { return } let server: String = next.key.lowercased() result[server] = CommunityManager.Server( @@ -130,7 +127,9 @@ public actor CommunityManager: CommunityManagerType { publicKey: publicKey, openGroups: next.value, capabilities: capabilities[server].map { Set($0) }, - members: members[threadId], + roomMembers: next.value.reduce(into: [:]) { result, next in + result[next.roomToken] = members[next.threadId] + }, using: dependencies ) } @@ -149,6 +148,14 @@ public actor CommunityManager: CommunityManagerType { } } + public func serversByThreadId() async -> [String: CommunityManager.Server] { + return _servers.values.reduce(into: [:]) { result, server in + server.rooms.forEach { roomToken, _ in + result[OpenGroup.idFor(roomToken: roomToken, server: server.server)] = server + } + } + } + public func updateServer(server: Server) async { _servers[server.server.lowercased()] = server } @@ -165,7 +172,7 @@ public actor CommunityManager: CommunityManagerType { publicKey: publicKey, openGroups: [], capabilities: capabilities, - members: nil, + roomMembers: nil, using: dependencies ) @@ -198,7 +205,7 @@ public actor CommunityManager: CommunityManagerType { publicKey: publicKey, openGroups: [], capabilities: nil, - members: nil, + roomMembers: nil, using: dependencies ) ) @@ -309,6 +316,7 @@ public actor CommunityManager: CommunityManagerType { roomToken: String, server: String, publicKey: String, + joinedAt: TimeInterval, forceVisible: Bool ) -> Bool { /// No need to do anything if the community is already in the cache @@ -334,6 +342,7 @@ public actor CommunityManager: CommunityManagerType { id: threadId, variant: .community, values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo(joinedAt), /// When adding an open group via config handling then we want to force it to be visible (if it did come via config /// handling then we want to wait until it actually has messages before making it visible) shouldBeVisible: (forceVisible ? .setTo(true) : .useExisting) @@ -344,7 +353,7 @@ public actor CommunityManager: CommunityManagerType { /// Update the state to allow polling and reset the `sequenceNumber` let openGroup: OpenGroup = OpenGroup .fetchOrCreate(db, server: targetServer, roomToken: roomToken, publicKey: publicKey) - .with(shouldPoll: true, sequenceNumber: 0) + .with(shouldPoll: .set(to: true), sequenceNumber: .set(to: 0)) try? openGroup.upsert(db) /// Update the cache to have a record of the new room @@ -405,7 +414,7 @@ public actor CommunityManager: CommunityManagerType { try Network.SOGS .preparedCapabilitiesAndRoom( roomToken: roomToken, - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: roomToken, server: server, @@ -506,7 +515,11 @@ public actor CommunityManager: CommunityManagerType { .filter(id: openGroupId) .deleteAll(db) - db.addConversationEvent(id: openGroupId, type: .deleted) + db.addConversationEvent( + id: openGroupId, + variant: .community, + type: .deleted + ) // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: syncState.dependencies) @@ -766,6 +779,7 @@ public actor CommunityManager: CommunityManagerType { if openGroup.name != pollInfo.details?.name { db.addConversationEvent( id: openGroup.id, + variant: .community, type: .updated(.displayName(pollInfo.details?.name ?? openGroup.name)) ) } @@ -773,12 +787,17 @@ public actor CommunityManager: CommunityManagerType { if openGroup.roomDescription == pollInfo.details?.roomDescription { db.addConversationEvent( id: openGroup.id, + variant: .community, type: .updated(.description(pollInfo.details?.roomDescription)) ) } if pollInfo.details?.imageId == nil { - db.addConversationEvent(id: openGroup.id, type: .updated(.displayPictureUrl(nil))) + db.addConversationEvent( + id: openGroup.id, + variant: .community, + type: .updated(.displayPictureUrl(nil)) + ) } } } @@ -1204,14 +1223,7 @@ public actor CommunityManager: CommunityManagerType { let room: Network.SOGS.Room = cachedServer.rooms[roomToken] else { return [] } - var result: Set = Set(room.admins + room.moderators) - - if includingHidden { - result.insert(contentsOf: Set(room.hiddenAdmins ?? [])) - result.insert(contentsOf: Set(room.hiddenModerators ?? [])) - } - - return result + return CommunityManager.allModeratorsAndAdmins(room: room, includingHidden: includingHidden) } /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @@ -1253,6 +1265,22 @@ public actor CommunityManager: CommunityManagerType { } } +public extension CommunityManagerType { + static func allModeratorsAndAdmins( + room: Network.SOGS.Room, + includingHidden: Bool + ) -> Set { + var result: Set = Set(room.admins + room.moderators) + + if includingHidden { + result.insert(contentsOf: Set(room.hiddenAdmins ?? [])) + result.insert(contentsOf: Set(room.hiddenModerators ?? [])) + } + + return result + } +} + // MARK: - SyncState private final class CommunityManagerSyncState { @@ -1310,6 +1338,7 @@ public protocol CommunityManagerType { func server(_ server: String) async -> CommunityManager.Server? func server(threadId: String) async -> CommunityManager.Server? + func serversByThreadId() async -> [String: CommunityManager.Server] func updateServer(server: CommunityManager.Server) async func updateCapabilities( capabilities: Set, @@ -1332,6 +1361,7 @@ public protocol CommunityManagerType { roomToken: String, server: String, publicKey: String, + joinedAt: TimeInterval, forceVisible: Bool ) -> Bool nonisolated func performInitialRequestsAfterAdd( diff --git a/SessionMessagingKit/Open Groups/Types/Server.swift b/SessionMessagingKit/Open Groups/Types/Server.swift index cbd203c65a..8d056ca06d 100644 --- a/SessionMessagingKit/Open Groups/Types/Server.swift +++ b/SessionMessagingKit/Open Groups/Types/Server.swift @@ -48,7 +48,7 @@ public extension CommunityManager.Server { publicKey: String, openGroups: [OpenGroup] = [], capabilities: Set? = nil, - members: [GroupMember]? = nil, + roomMembers: [String: [GroupMember]]? = nil, using dependencies: Dependencies ) { let currentUserSessionIds: Set = CommunityManager.Server.generateCurrentUserSessionIds( @@ -69,7 +69,7 @@ public extension CommunityManager.Server { self.rooms = openGroups.reduce(into: [:]) { result, next in result[next.roomToken] = Network.SOGS.Room( openGroup: next, - members: members, + members: (roomMembers?[next.roomToken] ?? []), currentUserSessionIds: currentUserSessionIds ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 715b61313e..c7132527af 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -406,7 +406,7 @@ extension MessageReceiver { message: message, disappearingMessagesConfiguration: try? DisappearingMessagesConfiguration .fetchOne(db, id: threadId), - authMethod: try Authentication.with(db, swarmPublicKey: threadId, using: dependencies), + authMethod: try Authentication.with(swarmPublicKey: threadId, using: dependencies), onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index cb63fb3246..40fbdeee4e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -754,7 +754,6 @@ extension MessageReceiver { cache.isAdmin(groupSessionId: groupSessionId) }), let authMethod: AuthenticationMethod = try? Authentication.with( - db, swarmPublicKey: groupSessionId.hexString, using: dependencies ) @@ -925,20 +924,17 @@ extension MessageReceiver { case .none: break case .some(let serverHash): db.afterCommit { - dependencies[singleton: .storage] - .readPublisher { db in - try Network.SnodeAPI.preparedDeleteMessages( - serverHashes: [serverHash], - requireSuccessfulDeletion: false, - authMethod: try Authentication.with( - db, - swarmPublicKey: userSessionId.hexString, - using: dependencies - ), + try? Network.SnodeAPI + .preparedDeleteMessages( + serverHashes: [serverHash], + requireSuccessfulDeletion: false, + authMethod: try Authentication.with( + swarmPublicKey: userSessionId.hexString, using: dependencies - ) - } - .flatMap { $0.send(using: dependencies) } + ), + using: dependencies + ) + .send(using: dependencies) .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .sinkUntilComplete() } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index ec2aa46540..6d577293d9 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import SessionNetworkingKit import SessionUtilitiesKit @@ -68,13 +69,12 @@ extension MessageReceiver { switch threadVariant { case .legacyGroup, .group, .community: break case .contact: - dependencies[singleton: .storage] - .readPublisher { db in + AnyPublisher + .lazy { try Network.SnodeAPI.preparedDeleteMessages( serverHashes: Array(hashes), requireSuccessfulDeletion: false, authMethod: try Authentication.with( - db, swarmPublicKey: dependencies[cache: .general].sessionId.hexString, using: dependencies ), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index b741d3b339..8e1a77adf7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -258,12 +258,17 @@ extension MessageSender { if name != closedGroup.name { groupChanges.append(ClosedGroup.Columns.name.set(to: name)) - db.addConversationEvent(id: groupSessionId, type: .updated(.displayName(name))) + db.addConversationEvent( + id: groupSessionId, + variant: .group, + type: .updated(.displayName(name)) + ) } if groupDescription != closedGroup.groupDescription { groupChanges.append(ClosedGroup.Columns.groupDescription.set(to: groupDescription)) db.addConversationEvent( id: groupSessionId, + variant: .group, type: .updated(.description(groupDescription)) ) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2613c9bf17..5d81794e5c 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -415,6 +415,7 @@ public enum MessageReceiver { try SessionThread.updateVisibility( db, threadId: threadId, + threadVariant: threadVariant, isVisible: true, additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 3dc2a45535..2d091dd97b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -248,8 +248,7 @@ public extension NotificationsManagerType { .defaulting(to: sender.truncated()), attachmentDescriptionInfo: attachmentDescriptionInfo?.first, attachmentCount: (attachmentDescriptionInfo?.count ?? 0), - isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil), - using: dependencies + isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil) ) }? .filteredForDisplay diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift index b868c35b3b..4b45a8423a 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift @@ -25,10 +25,20 @@ public extension Network.PushNotification { } return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> Set in + try ClosedGroup + .select(.threadId) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString + ) + .filter(ClosedGroup.Columns.shouldPoll) + .asRequest(of: String.self) + .fetchSet(db) + } + .tryMap { groupIds in let userSessionId: SessionId = dependencies[cache: .general].sessionId let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, swarmPublicKey: userSessionId.hexString, using: dependencies ) @@ -37,32 +47,22 @@ public extension Network.PushNotification { .preparedSubscribe( token: token, swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: try ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .filter(ClosedGroup.Columns.shouldPoll) - .asRequest(of: String.self) - .fetchSet(db) - .compactMap { threadId in - do { - return ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) + .appending(contentsOf: groupIds.compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + swarmPublicKey: threadId, + using: dependencies ) - } - catch { - Log.warn(.pushNotificationAPI, "Unable to subscribe for push notifications to \(threadId) due to error: \(error).") - return nil - } + ) } - ), + catch { + Log.warn(.pushNotificationAPI, "Skipping attempt to subscribe for push notifications for \(threadId) due to error: \(error).") + return nil + } + } + ), using: dependencies ) .handleEvents( @@ -85,10 +85,19 @@ public extension Network.PushNotification { using dependencies: Dependencies ) -> AnyPublisher { return dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in + .readPublisher { db -> Set in + ((try? ClosedGroup + .select(.threadId) + .filter( + ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && + ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString + ) + .asRequest(of: String.self) + .fetchSet(db)) ?? []) + } + .tryMap { groupIds in let userSessionId: SessionId = dependencies[cache: .general].sessionId let userAuthMethod: AuthenticationMethod = try Authentication.with( - db, swarmPublicKey: userSessionId.hexString, using: dependencies ) @@ -97,31 +106,21 @@ public extension Network.PushNotification { .preparedUnsubscribe( token: token, swarms: [(userSessionId, userAuthMethod)] - .appending(contentsOf: (try? ClosedGroup - .select(.threadId) - .filter( - ClosedGroup.Columns.threadId > SessionId.Prefix.group.rawValue && - ClosedGroup.Columns.threadId < SessionId.Prefix.group.endOfRangeString - ) - .asRequest(of: String.self) - .fetchSet(db)) - .defaulting(to: []) - .compactMap { threadId in - do { - return ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies - ) + .appending(contentsOf: groupIds.compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + swarmPublicKey: threadId, + using: dependencies ) - } - catch { - Log.info(.pushNotificationAPI, "Unable to unsubscribe for push notifications to \(threadId) due to error: \(error).") - return nil - } - }), + ) + } + catch { + Log.info(.pushNotificationAPI, "Skippint attempt to unsubscribe for push notifications from \(threadId) due to error: \(error).") + return nil + } + }), using: dependencies ) .handleEvents( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index 1ae823c7ae..6954e302a6 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -123,7 +123,6 @@ public class SwarmPoller: SwarmPollerType & PollerType { .tryFlatMapWithRandomSnode(drainBehaviour: _pollerDrainBehaviour, using: dependencies) { [pollerDestination, customAuthMethod, namespaces, dependencies] snode -> AnyPublisher<(LibSession.Snode, Network.PreparedRequest), Error> in dependencies[singleton: .storage].readPublisher { db -> (LibSession.Snode, Network.PreparedRequest) in let authMethod: AuthenticationMethod = try (customAuthMethod ?? Authentication.with( - db, swarmPublicKey: pollerDestination.target, using: dependencies )) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift deleted file mode 100644 index aed514fe24..0000000000 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ /dev/null @@ -1,2344 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation -import GRDB -import DifferenceKit -import SessionUIKit -import SessionUtilitiesKit - -fileprivate typealias ViewModel = SessionThreadViewModel - -/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the -/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each -/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places -/// -/// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values -/// in order to optimise their queries to only include the required data -// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) -public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, Decodable, Sendable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible, ThreadSafeType { - public typealias PagedDataType = SessionThread - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case threadId - case threadVariant - case threadCreationDateTimestamp - case threadMemberNames - - case threadIsNoteToSelf - case outdatedMemberId - case threadIsMessageRequest - case threadRequiresApproval - case threadShouldBeVisible - case threadPinnedPriority - case threadIsBlocked - case threadMutedUntilTimestamp - case threadOnlyNotifyForMentions - case threadMessageDraft - case threadIsDraft - - case threadContactIsTyping - case threadWasMarkedUnread - case threadUnreadCount - case threadUnreadMentionCount - case threadHasUnreadMessagesOfAnyKind - case threadCanWrite - case threadCanUpload - - // Thread display info - - case disappearingMessagesConfiguration - - case contactLastKnownClientVersion - case threadDisplayPictureUrl - case contactProfile - case closedGroupProfileFront - case closedGroupProfileBack - case closedGroupProfileBackFallback - case closedGroupAdminProfile - case closedGroupName - case closedGroupDescription - case closedGroupUserCount - case closedGroupExpired - case currentUserIsClosedGroupMember - case currentUserIsClosedGroupAdmin - case openGroupName - case openGroupDescription - case openGroupServer - case openGroupRoomToken - case openGroupPublicKey - case openGroupUserCount - case openGroupPermissions - case openGroupCapabilities - - // Interaction display info - - case interactionId - case interactionVariant - case interactionTimestampMs - case interactionBody - case interactionState - case interactionHasBeenReadByRecipient - case interactionIsOpenGroupInvitation - case interactionAttachmentDescriptionInfo - case interactionAttachmentCount - - case authorId - case threadContactNameInternal - case authorNameInternal - case currentUserSessionId - case currentUserSessionIds - case recentReactionEmoji - case wasKickedFromGroup - case groupIsDestroyed - case isContactApproved - } - - public var differenceIdentifier: String { threadId } - public var id: String { threadId } - - public let rowId: Int64 - public let threadId: String - public let threadVariant: SessionThread.Variant - private let threadCreationDateTimestamp: TimeInterval - public let threadMemberNames: String? - - public let threadIsNoteToSelf: Bool - public let outdatedMemberId: String? - - /// This flag indicates whether the thread is an outgoing message request - public let threadIsMessageRequest: Bool? - - /// This flag indicates whether the thread is an incoming message request - public let threadRequiresApproval: Bool? - public let threadShouldBeVisible: Bool? - public let threadPinnedPriority: Int32 - public let threadIsBlocked: Bool? - public let threadMutedUntilTimestamp: TimeInterval? - public let threadOnlyNotifyForMentions: Bool? - public let threadMessageDraft: String? - public let threadIsDraft: Bool? - - public let threadContactIsTyping: Bool? - public let threadWasMarkedUnread: Bool? - public let threadUnreadCount: UInt? - public let threadUnreadMentionCount: UInt? - public let threadHasUnreadMessagesOfAnyKind: Bool? - public let threadCanWrite: Bool? - public let threadCanUpload: Bool? - - // Thread display info - - public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? - - public let contactLastKnownClientVersion: FeatureVersion? - public let threadDisplayPictureUrl: String? - public let contactProfile: Profile? - public let closedGroupProfileFront: Profile? - public let closedGroupProfileBack: Profile? - internal let closedGroupProfileBackFallback: Profile? - public let closedGroupAdminProfile: Profile? - public let closedGroupName: String? - private let closedGroupDescription: String? - private let closedGroupUserCount: Int? - public let closedGroupExpired: Bool? - public let currentUserIsClosedGroupMember: Bool? - public let currentUserIsClosedGroupAdmin: Bool? - public let openGroupName: String? - private let openGroupDescription: String? - public let openGroupServer: String? - public let openGroupRoomToken: String? - public let openGroupPublicKey: String? - private let openGroupUserCount: Int? - private let openGroupPermissions: OpenGroup.Permissions? - public let openGroupCapabilities: Set? - - // Interaction display info - - public let interactionId: Int64? - public let interactionVariant: Interaction.Variant? - public let interactionTimestampMs: Int64? - public let interactionBody: String? - public let interactionState: Interaction.State? - public let interactionHasBeenReadByRecipient: Bool? - public let interactionIsOpenGroupInvitation: Bool? - public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo? - public let interactionAttachmentCount: Int? - - public let authorId: String? - private let threadContactNameInternal: String? - private let authorNameInternal: String? - public let currentUserSessionId: String - public let currentUserSessionIds: Set? - public let recentReactionEmoji: [String]? - public let wasKickedFromGroup: Bool? - public let groupIsDestroyed: Bool? - - /// Flag indicates that the contact's message request has been approved - public let isContactApproved: Bool? - - // UI specific logic - - public var displayName: String { - return SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: threadIsNoteToSelf, - ignoreNickname: false, - profile: profile - ) - } - - public var contactDisplayName: String { - return SessionThread.displayName( - threadId: threadId, - variant: threadVariant, - closedGroupName: closedGroupName, - openGroupName: openGroupName, - isNoteToSelf: threadIsNoteToSelf, - ignoreNickname: true, - profile: profile - ) - } - - public var threadDescription: String? { - switch threadVariant { - case .contact, .legacyGroup: return nil - case .community: return openGroupDescription - case .group: return closedGroupDescription - } - } - - public var allProfileIds: Set { - Set([ - authorId, contactProfile?.id, closedGroupProfileFront?.id, - closedGroupProfileBackFallback?.id, closedGroupAdminProfile?.id - ].compactMap { $0 }) - } - - public var profile: Profile? { - switch threadVariant { - case .contact: return contactProfile - case .legacyGroup, .group: - return (closedGroupProfileBack ?? closedGroupProfileBackFallback) - case .community: return nil - } - } - - public var additionalProfile: Profile? { - switch threadVariant { - case .legacyGroup, .group: return closedGroupProfileFront - default: return nil - } - } - - public var lastInteractionDate: Date { - guard let interactionTimestampMs: Int64 = interactionTimestampMs else { - return Date(timeIntervalSince1970: threadCreationDateTimestamp) - } - - return Date(timeIntervalSince1970: TimeInterval(Double(interactionTimestampMs) / 1000)) - } - - public var userCount: Int? { - switch threadVariant { - case .contact: return nil - case .legacyGroup, .group: return closedGroupUserCount - case .community: return openGroupUserCount - } - } - - /// This function returns the thread contact profile name formatted for the specific type of thread provided - /// - /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this - /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided - /// parameter - public func threadContactName() -> String { - return Profile.displayName( - id: threadId, - name: threadContactNameInternal, - nickname: nil, // Folded into 'threadContactNameInternal' within the Query - customFallback: "anonymous".localized() - ) - } - - /// This function returns the profile name formatted for the specific type of thread provided - /// - /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this - /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided - /// parameter - public func authorName(for threadVariant: SessionThread.Variant) -> String { - return Profile.displayName( - id: (authorId ?? threadId), - name: authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - customFallback: (threadVariant == .contact ? - "anonymous".localized() : - nil - ) - ) - } - - public func canAccessSettings(using dependencies: Dependencies) -> Bool { - return ( - threadRequiresApproval == false && - threadIsMessageRequest == false && - threadVariant != .legacyGroup - ) - } - - public func isSessionPro(using dependencies: Dependencies) -> Bool { - guard threadIsNoteToSelf == false && threadVariant != .community else { - return false - } - return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} - } - - public func getQRCodeString() -> String { - switch self.threadVariant { - case .contact, .legacyGroup, .group: - return self.threadId - - case .community: - guard - let urlString: String = LibSession.communityUrlFor( - server: self.openGroupServer, - roomToken: self.openGroupRoomToken, - publicKey: self.openGroupPublicKey - ) - else { return "" } - - return urlString - } - } - - // MARK: - Marking as Read - - public enum ReadTarget { - /// Only the thread should be marked as read - case thread - - /// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the - /// thread will be marked as read - case threadAndInteractions(interactionsBeforeInclusive: Int64?) - } - - /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read - public func markAsRead(target: ReadTarget, using dependencies: Dependencies) async throws { - let shouldMarkThreadAsUnread: Bool = (self.threadWasMarkedUnread == true) - let targetInteractionId: Int64? = { - guard case .threadAndInteractions(let interactionId) = target else { return nil } - guard threadHasUnreadMessagesOfAnyKind == true else { return nil } - - return (interactionId ?? self.interactionId) - }() - - /// No need to do anything if the thread is already marked as read and we don't have a target interaction - guard shouldMarkThreadAsUnread || targetInteractionId != nil else { return } - - /// Perform the updates - try await dependencies[singleton: .storage].writeAsync { db in - if shouldMarkThreadAsUnread { - try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.markedAsUnread.set(to: false), - using: dependencies - ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(false))) - } - - if let interactionId: Int64 = targetInteractionId { - try Interaction.markAsRead( - db, - interactionId: interactionId, - threadId: threadId, - threadVariant: threadVariant, - includingOlder: true, - trySendReadReceipt: SessionThread.canSendReadReceipt( - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ), - using: dependencies - ) - } - } - } - - /// This method will mark a thread as read - public func markAsUnread(using dependencies: Dependencies) async throws { - guard self.threadWasMarkedUnread != true else { return } - - let threadId: String = self.threadId - - try await dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAllAndConfig( - db, - SessionThread.Columns.markedAsUnread.set(to: true), - using: dependencies - ) - db.addConversationEvent(id: threadId, type: .updated(.markedAsUnread(true))) - } - } - - // MARK: - Draft - - public func updateDraft(_ draft: String, using dependencies: Dependencies) async throws { - let threadId: String = self.threadId - let existingDraft: String = (try await dependencies[singleton: .storage].readAsync { db in - try SessionThread - .select(.messageDraft) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) - } ?? "") - - guard draft != existingDraft else { return } - - try await dependencies[singleton: .storage].writeAsync { db in - try SessionThread - .filter(id: threadId) - .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) - db.addConversationEvent(id: threadId, type: .updated(.draft(draft))) - } - } - - // MARK: - Functions - - /// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use - /// `threadCanWrite == true` to determine whether the user should be able to write to a thread, this function uses - /// external data to determine if the user can write so the result might differ from the original value when the - /// `SessionThreadViewModel` was created - public func determineInitialCanWriteFlag(using dependencies: Dependencies) -> Bool { - switch threadVariant { - case .contact: - guard threadIsMessageRequest == true else { return true } - - // If the thread is an incoming message request then we should be able to reply - // regardless of the original senders `blocksCommunityMessageRequests` setting - guard threadRequiresApproval == true else { return true } - - return (profile?.blocksCommunityMessageRequests != true) - - case .legacyGroup: return false - case .group: - guard groupIsDestroyed != true else { return false } - guard wasKickedFromGroup != true else { return false } - guard threadIsMessageRequest == false else { return true } - - /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && - !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - }) - else { return false } - - return interactionVariant?.isGroupLeavingStatus != true - - case .community: - return (openGroupPermissions?.contains(.write) ?? false) - } - } - - /// This function should only be called when initially creating/populating the `SessionThreadViewModel`, instead use - /// `threadCanUpload == true` to determine whether the user should be able to write to a thread, this function uses - /// external data to determine if the user can write so the result might differ from the original value when the - /// `SessionThreadViewModel` was created - public func determineInitialCanUploadFlag(using dependencies: Dependencies) -> Bool { - switch threadVariant { - case .contact: - // If the thread is an outgoing message request then we shouldn't be able to upload - return (threadRequiresApproval == false) - - case .legacyGroup: return false - case .group: - guard groupIsDestroyed != true else { return false } - guard wasKickedFromGroup != true else { return false } - guard threadIsMessageRequest == false else { return true } - - /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed - guard - dependencies.mutate(cache: .libSession, { cache in - !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && - !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) - }) - else { return false } - - return interactionVariant?.isGroupLeavingStatus != true - - case .community: - return (openGroupPermissions?.contains(.upload) ?? false) - } - } -} - -// MARK: - Convenience Initialization - -public extension SessionThreadViewModel { - static let invalidId: String = "INVALID_THREAD_ID" - static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID" - - // Note: This init method is only used system-created cells or empty states - init( - threadId: String, - threadVariant: SessionThread.Variant? = nil, - threadIsNoteToSelf: Bool = false, - threadIsMessageRequest: Bool? = nil, - threadIsBlocked: Bool? = nil, - contactProfile: Profile? = nil, - closedGroupAdminProfile: Profile? = nil, - closedGroupExpired: Bool? = nil, - currentUserIsClosedGroupMember: Bool? = nil, - currentUserIsClosedGroupAdmin: Bool? = nil, - openGroupPermissions: OpenGroup.Permissions? = nil, - threadWasMarkedUnread: Bool? = nil, - unreadCount: UInt = 0, - hasUnreadMessagesOfAnyKind: Bool = false, - threadCanWrite: Bool = true, - threadCanUpload: Bool = true, - disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil, - using dependencies: Dependencies - ) { - self.rowId = -1 - self.threadId = threadId - self.threadVariant = (threadVariant ?? .contact) - self.threadCreationDateTimestamp = 0 - self.threadMemberNames = nil - - self.threadIsNoteToSelf = threadIsNoteToSelf - self.outdatedMemberId = nil - self.threadIsMessageRequest = threadIsMessageRequest - self.threadRequiresApproval = false - self.threadShouldBeVisible = false - self.threadPinnedPriority = 0 - self.threadIsBlocked = threadIsBlocked - self.threadMutedUntilTimestamp = nil - self.threadOnlyNotifyForMentions = nil - self.threadMessageDraft = nil - self.threadIsDraft = nil - - self.threadContactIsTyping = nil - self.threadWasMarkedUnread = threadWasMarkedUnread - self.threadUnreadCount = unreadCount - self.threadUnreadMentionCount = nil - self.threadHasUnreadMessagesOfAnyKind = hasUnreadMessagesOfAnyKind - self.threadCanWrite = threadCanWrite - self.threadCanUpload = threadCanUpload - - // Thread display info - - self.disappearingMessagesConfiguration = disappearingMessagesConfiguration - - self.contactLastKnownClientVersion = nil - self.threadDisplayPictureUrl = nil - self.contactProfile = contactProfile - self.closedGroupProfileFront = nil - self.closedGroupProfileBack = nil - self.closedGroupProfileBackFallback = nil - self.closedGroupAdminProfile = closedGroupAdminProfile - self.closedGroupName = nil - self.closedGroupDescription = nil - self.closedGroupUserCount = nil - self.closedGroupExpired = closedGroupExpired - self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember - self.currentUserIsClosedGroupAdmin = currentUserIsClosedGroupAdmin - self.openGroupName = nil - self.openGroupDescription = nil - self.openGroupServer = nil - self.openGroupRoomToken = nil - self.openGroupPublicKey = nil - self.openGroupUserCount = nil - self.openGroupPermissions = openGroupPermissions - self.openGroupCapabilities = nil - - // Interaction display info - - self.interactionId = nil - self.interactionVariant = nil - self.interactionTimestampMs = nil - self.interactionBody = nil - self.interactionState = nil - self.interactionHasBeenReadByRecipient = nil - self.interactionIsOpenGroupInvitation = nil - self.interactionAttachmentDescriptionInfo = nil - self.interactionAttachmentCount = nil - - self.authorId = nil - self.threadContactNameInternal = nil - self.authorNameInternal = nil - self.currentUserSessionId = dependencies[cache: .general].sessionId.hexString - self.currentUserSessionIds = [dependencies[cache: .general].sessionId.hexString] - self.recentReactionEmoji = nil - self.wasKickedFromGroup = false - self.groupIsDestroyed = false - self.isContactApproved = false - } -} - -// MARK: - Mutation - -public extension SessionThreadViewModel { - func populatingPostQueryData( - recentReactionEmoji: [String]?, - openGroupCapabilities: Set?, - currentUserSessionIds: Set, - wasKickedFromGroup: Bool, - groupIsDestroyed: Bool, - threadCanWrite: Bool, - threadCanUpload: Bool - ) -> SessionThreadViewModel { - return SessionThreadViewModel( - rowId: self.rowId, - threadId: self.threadId, - threadVariant: self.threadVariant, - threadCreationDateTimestamp: self.threadCreationDateTimestamp, - threadMemberNames: self.threadMemberNames, - threadIsNoteToSelf: self.threadIsNoteToSelf, - outdatedMemberId: self.outdatedMemberId, - threadIsMessageRequest: self.threadIsMessageRequest, - threadRequiresApproval: self.threadRequiresApproval, - threadShouldBeVisible: self.threadShouldBeVisible, - threadPinnedPriority: self.threadPinnedPriority, - threadIsBlocked: self.threadIsBlocked, - threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, - threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, - threadMessageDraft: self.threadMessageDraft, - threadIsDraft: self.threadIsDraft, - threadContactIsTyping: self.threadContactIsTyping, - threadWasMarkedUnread: self.threadWasMarkedUnread, - threadUnreadCount: self.threadUnreadCount, - threadUnreadMentionCount: self.threadUnreadMentionCount, - threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, - threadCanWrite: threadCanWrite, - threadCanUpload: threadCanUpload, - disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, - contactLastKnownClientVersion: self.contactLastKnownClientVersion, - threadDisplayPictureUrl: self.threadDisplayPictureUrl, - contactProfile: self.contactProfile, - closedGroupProfileFront: self.closedGroupProfileFront, - closedGroupProfileBack: self.closedGroupProfileBack, - closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, - closedGroupAdminProfile: self.closedGroupAdminProfile, - closedGroupName: self.closedGroupName, - closedGroupDescription: self.closedGroupDescription, - closedGroupUserCount: self.closedGroupUserCount, - closedGroupExpired: self.closedGroupExpired, - currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, - openGroupName: self.openGroupName, - openGroupDescription: self.openGroupDescription, - openGroupServer: self.openGroupServer, - openGroupRoomToken: self.openGroupRoomToken, - openGroupPublicKey: self.openGroupPublicKey, - openGroupUserCount: self.openGroupUserCount, - openGroupPermissions: self.openGroupPermissions, - openGroupCapabilities: openGroupCapabilities, - interactionId: self.interactionId, - interactionVariant: self.interactionVariant, - interactionTimestampMs: self.interactionTimestampMs, - interactionBody: self.interactionBody, - interactionState: self.interactionState, - interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, - interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, - interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, - interactionAttachmentCount: self.interactionAttachmentCount, - authorId: self.authorId, - threadContactNameInternal: self.threadContactNameInternal, - authorNameInternal: self.authorNameInternal, - currentUserSessionId: self.currentUserSessionId, - currentUserSessionIds: currentUserSessionIds, - recentReactionEmoji: recentReactionEmoji, - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - isContactApproved: isContactApproved - ) - } -} - -// MARK: - AggregateInteraction - -private struct AggregateInteraction: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case interactionId - case threadId - case interactionTimestampMs - case threadUnreadCount - case threadUnreadMentionCount - case threadHasUnreadMessagesOfAnyKind - } - - let interactionId: Int64 - let threadId: String - let interactionTimestampMs: Int64 - let threadUnreadCount: UInt? - let threadUnreadMentionCount: UInt? - let threadHasUnreadMessagesOfAnyKind: Bool? -} - -// MARK: - ClosedGroupUserCount - -private struct ClosedGroupUserCount: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case groupId - case closedGroupUserCount - } - - let groupId: String - let closedGroupUserCount: Int -} - -// MARK: - GroupMemberInfo - -private struct GroupMemberInfo: Decodable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case groupId - case threadMemberNames - } - - let groupId: String - let threadMemberNames: String -} - -// MARK: - HomeVC & MessageRequestsViewModel - -// MARK: --SessionThreadViewModel - -public extension SessionThreadViewModel { - static func query( - userSessionId: SessionId, - groupSQL: SQL, - orderSQL: SQL, - ids: [String] - ) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = TypedTableAlias() - let firstInteractionAttachment: TypedTableAlias = TypedTableAlias(name: "firstInteractionAttachment") - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 21 - let numColumnsBetweenProfilesAndAttachmentInfo: Int = 19 // The attachment info columns will be combined - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - ( - SELECT \(contactProfile[.id]) - FROM \(contactProfile.self) - LEFT JOIN \(contact.self) ON \(contactProfile[.id]) = \(contact[.id]) - LEFT JOIN \(groupMember.self) ON \(groupMember[.groupId]) = \(thread[.id]) - WHERE ( - (\(groupMember[.profileId]) = \(contactProfile[.id]) OR - \(contact[.id]) = \(thread[.id])) AND - \(contact[.id]) <> \(userSessionId.hexString) AND - \(contact[.lastKnownClientVersion]) = \(FeatureVersion.legacyDisappearingMessages) - ) - ) AS \(ViewModel.Columns.outdatedMemberId), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.Columns.threadRequiresApproval), - \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), - \(thread[.isDraft]) AS \(ViewModel.Columns.threadIsDraft), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - - false AS \(ViewModel.Columns.threadContactIsTyping), - \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), - \(aggregateInteraction[.threadUnreadCount]), - \(aggregateInteraction[.threadUnreadMentionCount]), - \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroupUserCount[.closedGroupUserCount]), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), - \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), - \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), - \(interaction[.body]) AS \(ViewModel.Columns.interactionBody), - \(interaction[.state]) AS \(ViewModel.Columns.interactionState), - (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.interactionHasBeenReadByRecipient), - (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.Columns.interactionIsOpenGroupInvitation), - - -- These 4 properties will be combined into 'Attachment.DescriptionInfo' - \(attachment[.id]), - \(attachment[.variant]), - \(attachment[.contentType]), - \(attachment[.sourceFilename]), - COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.Columns.interactionAttachmentCount), - - \(interaction[.authorId]), - IFNULL(\(contactProfile[.nickname]), \(contactProfile[.name])) AS \(ViewModel.Columns.threadContactNameInternal), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), - SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(AggregateInteraction.Columns.threadUnreadMentionCount), - (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) - - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteraction[.interactionId]) - ) - - LEFT JOIN \(LinkPreview.self) ON ( - \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) - ) - LEFT JOIN \(firstInteractionAttachment) ON ( - \(firstInteractionAttachment[.interactionId]) = \(interaction[.id]) AND - \(firstInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachment[.attachmentId]) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - - -- Thread naming & avatar content - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile) ON ( - \(closedGroupAdminProfile[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) - ) - ) - ) - - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - COUNT(DISTINCT \(groupMember[.profileId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) - FROM \(GroupMember.self) - WHERE \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) - GROUP BY \(groupMember[.groupId]) - ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(closedGroup[.threadId])")) - - WHERE \(thread[.id]) IN \(ids) - \(groupSQL) - ORDER BY \(orderSQL) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - numColumnsBetweenProfilesAndAttachmentInfo, - Attachment.DescriptionInfo.numberOfSelectedColumns() - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5], - .interactionAttachmentDescriptionInfo: adapters[7] - ]) - } - } - - static var optimisedJoinSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - - return """ - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.threadId]), - MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - """ - }() - - static func homeFilterSQL(userSessionId: SessionId) -> SQL { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - - return """ - \(thread[.shouldBeVisible]) = true AND - -- Is not a message request - COALESCE(\(closedGroup[.invited]), false) = false AND ( - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR - \(contact[.isApproved]) = true - ) AND - -- Is not a blocked contact - ( - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(contact[.isBlocked]) != true - ) - """ - } - - static func messageRequestsFilterSQL(userSessionId: SessionId) -> SQL { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let closedGroup: TypedTableAlias = TypedTableAlias() - - return """ - \(thread[.shouldBeVisible]) = true AND ( - -- Is a message request - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) - """ - } - - static let groupSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - - return SQL("GROUP BY \(thread[.id])") - }() - - static let homeOrderSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL(""" - (IFNULL(\(thread[.pinnedPriority]), 0) > 0) DESC, - IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC - """) - }() - - static let messageRequestsOrderSQL: SQL = { - let thread: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC") - }() -} - -// MARK: - ConversationVC - -public extension SessionThreadViewModel { - /// **Note:** This query **will** include deleted incoming messages in it's unread count (they should never be marked as unread - /// but including this warning just in case there is a discrepancy) - static func conversationQuery(threadId: String, userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - let closedGroupUserCount: TypedTableAlias = TypedTableAlias(name: "closedGroupUserCount") - let profile: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `disappearingMessageSConfiguration` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 18 - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - ( - SELECT \(contactProfile[.id]) - FROM \(contactProfile.self) - LEFT JOIN \(contact.self) ON \(contactProfile[.id]) = \(contact[.id]) - LEFT JOIN \(groupMember.self) ON \(groupMember[.groupId]) = \(threadId) - WHERE ( - (\(groupMember[.profileId]) = \(contactProfile[.id]) OR - \(contact[.id]) = \(threadId)) AND - \(contact[.id]) <> \(userSessionId.hexString) AND - \(contact[.lastKnownClientVersion]) = \(FeatureVersion.legacyDisappearingMessages) - ) - ) AS \(ViewModel.Columns.outdatedMemberId), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.Columns.threadRequiresApproval), - \(thread[.shouldBeVisible]) AS \(ViewModel.Columns.threadShouldBeVisible), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - \(thread[.messageDraft]) AS \(ViewModel.Columns.threadMessageDraft), - \(thread[.isDraft]) AS \(ViewModel.Columns.threadIsDraft), - - \(thread[.markedAsUnread]) AS \(ViewModel.Columns.threadWasMarkedUnread), - \(aggregateInteraction[.threadUnreadCount]), - \(aggregateInteraction[.threadHasUnreadMessagesOfAnyKind]), - - \(disappearingMessagesConfiguration.allColumns), - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(contact[.lastKnownClientVersion]) AS \(ViewModel.Columns.contactLastKnownClientVersion), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroupUserCount[.closedGroupUserCount]), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), - \(openGroup[.userCount]) AS \(ViewModel.Columns.openGroupUserCount), - \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(aggregateInteraction[.interactionId]), - \(aggregateInteraction[.interactionTimestampMs]), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - SUM(\(interaction[.wasRead]) = false) AS \(AggregateInteraction.Columns.threadUnreadCount), - 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount), - (SUM(\(interaction[.wasRead]) = false) > 0) AS \(AggregateInteraction.Columns.threadHasUnreadMessagesOfAnyKind) - FROM \(Interaction.self) - WHERE ( - \(SQL("\(interaction[.threadId]) = \(threadId)")) AND - \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) - ) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - COUNT(DISTINCT \(groupMember[.profileId])) AS \(ClosedGroupUserCount.Columns.closedGroupUserCount) - FROM \(GroupMember.self) - WHERE ( - \(SQL("\(groupMember[.groupId]) = \(threadId)")) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) - ) - ) AS \(closedGroupUserCount) ON \(SQL("\(closedGroupUserCount[.groupId]) = \(threadId)")) - - WHERE \(SQL("\(thread[.id]) = \(threadId)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - DisappearingMessagesConfiguration.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .disappearingMessagesConfiguration: adapters[1], - .contactProfile: adapters[2], - .closedGroupProfileFront: adapters[3], - .closedGroupProfileBack: adapters[4], - .closedGroupProfileBackFallback: adapters[5] - ]) - } - } - - static func conversationSettingsQuery(threadId: String, userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 9 - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - \(thread[.mutedUntilTimestamp]) AS \(ViewModel.Columns.threadMutedUntilTimestamp), - \(thread[.onlyNotifyForMentions]) AS \(ViewModel.Columns.threadOnlyNotifyForMentions), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.groupDescription]) AS \(ViewModel.Columns.closedGroupDescription), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) AND ( - ( - -- Legacy groups don't have a 'roleStatus' so just let those through - -- based solely on the 'role' - \(groupMember[.groupId]) > \(SessionId.Prefix.standard.rawValue) AND - \(groupMember[.groupId]) < \(SessionId.Prefix.standard.endOfRangeString) - ) OR - \(SQL("\(groupMember[.roleStatus]) = \(GroupMember.RoleStatus.accepted)")) - ) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupAdmin), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.roomDescription]) AS \(ViewModel.Columns.openGroupDescription), - \(openGroup[.server]) AS \(ViewModel.Columns.openGroupServer), - \(openGroup[.roomToken]) AS \(ViewModel.Columns.openGroupRoomToken), - \(openGroup[.publicKey]) AS \(ViewModel.Columns.openGroupPublicKey), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - WHERE \(SQL("\(thread[.id]) = \(threadId)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } -} - -// MARK: - Search Queries - -public extension SessionThreadViewModel { - static let searchResultsLimit: Int = 500 - - /// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search - /// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL - /// is `MATCH '"{term}"'` or `MATCH '"{term}"*'` - static func searchSafeTerm(_ term: String) -> String { - return "\"\(term)\"" - } - - static func searchTermParts(_ searchTerm: String) -> [String] { - /// Process the search term in order to extract the parts of the search pattern we want - /// - /// Step 1 - Keep any "quoted" sections as stand-alone search - /// Step 2 - Separate any words outside of quotes - /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) - /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) - let normalisedTerm: String = standardQuotes(searchTerm) - - guard let regex = try? NSRegularExpression(pattern: "[^\\s\"']+|\"([^\"]*)\"") else { - // Fallback to removing the quotes and just splitting on spaces - return normalisedTerm - .replacingOccurrences(of: "\"", with: "") - .split(separator: " ") - .map { "\"\($0)\"" } - .filter { !$0.isEmpty } - } - - return regex - .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) - .compactMap { Range($0.range, in: normalisedTerm) } - .map { normalisedTerm[$0].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } - .map { "\"\($0)\"" } - } - - static func standardQuotes(_ term: String) -> String { - // Apple like to use the special '""' quote characters when typing so replace them with normal ones - return term - .replacingOccurrences(of: "”", with: "\"") - .replacingOccurrences(of: "“", with: "\"") - } - - static func pattern(_ db: ObservingDatabase, searchTerm: String) throws -> FTS5Pattern { - return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) - } - - static func pattern(_ db: ObservingDatabase, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { - // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to - // add a prefix one - let rawPattern: String = { - let result: String = searchTermParts(searchTerm) - .joined(separator: " OR ") - - // If the last character is a quotation mark then assume the user doesn't want to append - // a wildcard character - guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } - - return "\(result)*" - }() - let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" - - /// There are cases where creating a pattern can fail, we want to try and recover from those cases - /// by failling back to simpler patterns if needed - return try { - if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { - return pattern - } - - if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { - return pattern - } - - return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() - }() - } - - static func messagesQuery(userSessionId: SessionId, pattern: FTS5Pattern) -> AdaptedFetchRequest> { - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let interactionFullTextSearch: TypedTableAlias = TypedTableAlias(name: Interaction.fullTextSearchTableName) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to - /// parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 6 - let request: SQLRequest = """ - SELECT - \(interaction[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - \(interaction[.timestampMs]) AS \(ViewModel.Columns.interactionTimestampMs), - snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.Columns.interactionBody), - - \(interaction[.authorId]), - IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.Columns.authorNameInternal), - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Interaction.self) - JOIN \(interactionFullTextSearch) ON ( - \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND - \(interactionFullTextSearch[.body]) MATCH \(pattern) - ) - JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) - JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(interaction[.threadId]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(interaction[.threadId]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(userSessionId.hexString) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) - LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } - - /// This method does an FTS search against threads and their contacts to find any which contain the pattern - /// - /// **Note:** Unfortunately the FTS search only allows for a single pattern match per query which means we - /// need to combine the results of **all** of the following potential matches as unioned queries: - /// - Contact thread contact nickname - /// - Contact thread contact name - /// - Closed group name - /// - Closed group member nickname - /// - Closed group member name - /// - Open group name - /// - "Note to self" text match - /// - Hidden contact nickname - /// - Hidden contact name - /// - /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the - /// returned results will always be `-1` for those results - static func contactsAndGroupsQuery(userSessionId: SessionId, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let groupMemberProfile: TypedTableAlias = TypedTableAlias(name: "groupMemberProfile") - let openGroup: TypedTableAlias = TypedTableAlias() - let groupMemberInfo: TypedTableAlias = TypedTableAlias(name: "groupMemberInfo") - let profile: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let profileFullTextSearch: TypedTableAlias = TypedTableAlias(name: Profile.fullTextSearchTableName) - let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: ClosedGroup.fullTextSearchTableName) - let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias(name: OpenGroup.fullTextSearchTableName) - - let noteToSelfLiteral: SQL = SQL(stringLiteral: "noteToSelf".localized().lowercased()) - let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null - /// `rank` value which ends up as the first result, by defaulting to `100` it will always be ranked last compared - /// to any relevance-based results - let numColumnsBeforeProfiles: Int = 8 - var sqlQuery: SQL = "" - let selectQuery: SQL = """ - SELECT - IFNULL(\(Column.rank), 100) AS \(Column.rank), - - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - \(groupMemberInfo[.threadMemberNames]), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - - """ - - // MARK: --Contact Threads - let contactQueryCommonJoinFilterGroup: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) - GROUP BY \(thread[.id]) - """ - - // Contact thread nickname searching (ignoring note to self - handled separately) - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += contactQueryCommonJoinFilterGroup - - // Contact thread name searching (ignoring note to self - handled separately) - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += contactQueryCommonJoinFilterGroup - - // MARK: --Closed Group Threads - let closedGroupQueryCommonJoinFilterGroup: SQL = """ - JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(thread[.id]) - ) - LEFT JOIN ( - SELECT - \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - GROUP BY \(groupMember[.groupId]) - ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(groupMember[.profileId]) != \(userSessionId.hexString) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(userSessionId.hexString) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - LEFT JOIN \(contactProfile.never) - LEFT JOIN \(openGroup.never) - - WHERE ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) - ) - GROUP BY \(thread[.id]) - """ - - // Closed group thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(closedGroupFullTextSearch) ON ( - \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND - \(closedGroupFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // Closed group member nickname searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // Closed group member name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += closedGroupQueryCommonJoinFilterGroup - - // MARK: --Open Group Threads - // Open group thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - JOIN \(openGroupFullTextSearch) ON ( - \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND - \(openGroupFullTextSearch[.name]) MATCH \(pattern) - ) - LEFT JOIN \(contactProfile.never) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) - GROUP BY \(thread[.id]) - """ - - // MARK: --Note to Self Thread - let noteToSelfQueryCommonJoins: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(groupMemberInfo.never) - """ - - // Note to self thread searching for 'Note to Self' (need to join an FTS table to - // ensure there is a 'rank' column) - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - LEFT JOIN \(profileFullTextSearch) ON false - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) AND - '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' - """ - - // Note to self thread nickname searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // Note to self thread name searching - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += selectQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += noteToSelfQueryCommonJoins - sqlQuery += """ - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // MARK: --Contacts without threads - let hiddenContactQuery: SQL = """ - SELECT - IFNULL(\(Column.rank), 100) AS \(Column.rank), - - -1 AS \(ViewModel.Columns.rowId), - \(contact[.id]) AS \(ViewModel.Columns.threadId), - \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.Columns.threadVariant), - 0 AS \(ViewModel.Columns.threadCreationDateTimestamp), - \(groupMemberInfo[.threadMemberNames]), - - false AS \(ViewModel.Columns.threadIsNoteToSelf), - -1 AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Contact.self) - """ - let hiddenContactQueryCommonJoins: SQL = """ - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) - LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(closedGroupProfileFront.never) - LEFT JOIN \(closedGroupProfileBack.never) - LEFT JOIN \(closedGroupProfileBackFallback.never) - LEFT JOIN \(closedGroupAdminProfile.never) - LEFT JOIN \(closedGroup.never) - LEFT JOIN \(openGroup.never) - LEFT JOIN \(groupMemberInfo.never) - - WHERE \(thread[.id]) IS NULL - GROUP BY \(contact[.id]) - """ - - // Hidden contact by nickname - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += hiddenContactQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.nickname]) MATCH \(pattern) - ) - """ - sqlQuery += hiddenContactQueryCommonJoins - - // Hidden contact by name - sqlQuery += """ - - UNION ALL - - """ - sqlQuery += hiddenContactQuery - sqlQuery += """ - - JOIN \(profileFullTextSearch) ON ( - \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND - \(profileFullTextSearch[.name]) MATCH \(pattern) - ) - """ - sqlQuery += hiddenContactQueryCommonJoins - - // Group everything by 'threadId' (the same thread can be found in multiple queries due - // to seaerching both nickname and name), then order everything by 'rank' (relevance) - // first, 'Note to Self' second (want it to appear at the bottom of threads unless it - // has relevance) adn then try to group and sort based on thread type and names - let finalQuery: SQL = """ - SELECT * - FROM ( - \(sqlQuery) - ) - - GROUP BY \(ViewModel.Columns.threadId) - ORDER BY - \(Column.rank), - \(ViewModel.Columns.threadIsNoteToSelf), - \(ViewModel.Columns.closedGroupName), - \(ViewModel.Columns.openGroupName), - \(ViewModel.Columns.threadId) - LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) - """ - - // Construct the actual request - let request: SQLRequest = SQLRequest( - literal: finalQuery, - adapter: RenameColumnAdapter { column in - // Note: The query automatically adds a suffix to the various profile columns - // to make them easier to distinguish (ie. 'id' -> 'id:1') - this breaks the - // decoding so we need to strip the information after the colon - guard column.contains(":") else { return column } - - return String(column.split(separator: ":")[0]) - }, - cached: false - ) - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } - - static func defaultContactsQuery(using dependencies: Dependencies) -> AdaptedFetchRequest> { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let currentTimestamp: TimeInterval = dependencies.dateNow.timeIntervalSince1970 - - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 9 - let request: SQLRequest = """ - SELECT - 100 AS \(Column.rank), - - \(contact[.rowId]) AS \(ViewModel.Columns.rowId), - \(contact[.id]) AS \(ViewModel.Columns.threadId), - \(contact[.isApproved]) AS \(ViewModel.Columns.isContactApproved), - \(SessionThread.Variant.contact) AS \(ViewModel.Columns.threadVariant), - IFNULL(\(thread[.creationDateTimestamp]), \(currentTimestamp)) AS \(ViewModel.Columns.threadCreationDateTimestamp), - '' AS \(ViewModel.Columns.threadMemberNames), - - (\(SQL("\(contact[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(Contact.self) - LEFT JOIN \(thread) ON \(thread[.id]) = \(contact[.id]) - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) - WHERE \(contact[.isBlocked]) = false - """ - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1] - ]) - } - } - - /// This method returns only the 'Note to Self' thread in the structure of a search result conversation - static func noteToSelfOnlyQuery(userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 8 - let request: SQLRequest = """ - SELECT - 100 AS \(Column.rank), - - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - '' AS \(ViewModel.Columns.threadMemberNames), - - true AS \(ViewModel.Columns.threadIsNoteToSelf), - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - - \(contactProfile.allColumns), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - - WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - """ - - // Add adapters which will group the various 'Profile' columns so they can be decoded - // as instances of 'Profile' types - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1] - ]) - } - } -} - -// MARK: - Share Extension - -public extension SessionThreadViewModel { - static func shareQuery(userSessionId: SessionId) -> AdaptedFetchRequest> { - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) - let closedGroup: TypedTableAlias = TypedTableAlias() - let closedGroupProfileFront: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileFront) - let closedGroupProfileBack: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBack) - let closedGroupProfileBackFallback: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupProfileBackFallback) - let closedGroupAdminProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .closedGroupAdminProfile) - let groupMember: TypedTableAlias = TypedTableAlias() - let openGroup: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let aggregateInteraction: TypedTableAlias = TypedTableAlias(name: "aggregateInteraction") - let interaction: TypedTableAlias = TypedTableAlias() - - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before - /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - /// - /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 9 - - let request: SQLRequest = """ - SELECT - \(thread[.rowId]) AS \(ViewModel.Columns.rowId), - \(thread[.id]) AS \(ViewModel.Columns.threadId), - \(thread[.variant]) AS \(ViewModel.Columns.threadVariant), - \(thread[.creationDateTimestamp]) AS \(ViewModel.Columns.threadCreationDateTimestamp), - - (\(SQL("\(thread[.id]) = \(userSessionId.hexString)"))) AS \(ViewModel.Columns.threadIsNoteToSelf), - ( - COALESCE(\(closedGroup[.invited]), false) = true OR ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND - IFNULL(\(contact[.isApproved]), false) = false - ) - ) AS \(ViewModel.Columns.threadIsMessageRequest), - ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND - IFNULL(\(contact[.didApproveMe]), false) = false - ) AS \(ViewModel.Columns.threadRequiresApproval), - - IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.Columns.threadPinnedPriority), - \(contact[.isBlocked]) AS \(ViewModel.Columns.threadIsBlocked), - - \(contactProfile.allColumns), - \(closedGroupProfileFront.allColumns), - \(closedGroupProfileBack.allColumns), - \(closedGroupProfileBackFallback.allColumns), - \(closedGroupAdminProfile.allColumns), - \(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName), - \(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired), - - EXISTS ( - SELECT 1 - FROM \(GroupMember.self) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND - \(SQL("\(groupMember[.profileId]) = \(userSessionId.hexString)")) - ) - ) AS \(ViewModel.Columns.currentUserIsClosedGroupMember), - - \(openGroup[.name]) AS \(ViewModel.Columns.openGroupName), - \(openGroup[.permissions]) AS \(ViewModel.Columns.openGroupPermissions), - - COALESCE( - \(openGroup[.displayPictureOriginalUrl]), - \(closedGroup[.displayPictureUrl]), - \(contactProfile[.displayPictureUrl]) - ) AS \(ViewModel.Columns.threadDisplayPictureUrl), - - \(interaction[.id]) AS \(ViewModel.Columns.interactionId), - \(interaction[.variant]) AS \(ViewModel.Columns.interactionVariant), - - \(SQL("\(userSessionId.hexString)")) AS \(ViewModel.Columns.currentUserSessionId) - - FROM \(SessionThread.self) - LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) - - LEFT JOIN ( - SELECT - \(interaction[.id]) AS \(AggregateInteraction.Columns.interactionId), - \(interaction[.threadId]) AS \(AggregateInteraction.Columns.threadId), - MAX(\(interaction[.timestampMs])) AS \(AggregateInteraction.Columns.interactionTimestampMs), - 0 AS \(AggregateInteraction.Columns.threadUnreadCount), - 0 AS \(AggregateInteraction.Columns.threadUnreadMentionCount) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) - GROUP BY \(interaction[.threadId]) - ) AS \(aggregateInteraction) ON \(aggregateInteraction[.threadId]) = \(thread[.id]) - LEFT JOIN \(Interaction.self) ON ( - \(interaction[.threadId]) = \(thread[.id]) AND - \(interaction[.id]) = \(aggregateInteraction[.interactionId]) - ) - - LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) - LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) - - LEFT JOIN \(closedGroupProfileFront) ON ( - \(closedGroupProfileFront[.id]) = ( - SELECT MIN(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBack) ON ( - \(closedGroupProfileBack[.id]) != \(closedGroupProfileFront[.id]) AND - \(closedGroupProfileBack[.id]) = ( - SELECT MAX(\(groupMember[.profileId])) - FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) != \(userSessionId.hexString)")) - ) - ) - ) - LEFT JOIN \(closedGroupProfileBackFallback) ON ( - \(closedGroup[.threadId]) IS NOT NULL AND - \(closedGroupProfileBack[.id]) IS NULL AND - \(closedGroupProfileBackFallback[.id]) = \(SQL("\(userSessionId.hexString)")) - ) - LEFT JOIN \(closedGroupAdminProfile.never) - - WHERE ( - \(thread[.shouldBeVisible]) = true AND - COALESCE(\(closedGroup[.invited]), false) = false AND ( - -- Is not a message request - \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR - \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR - \(contact[.isApproved]) = true - ) - -- Always show the 'Note to Self' thread when sharing - OR \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) - ) - - GROUP BY \(thread[.id]) - -- 'Note to Self', then by most recent message - ORDER BY \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC - """ - - return request.adapted { db in - let adapters = try splittingRowAdapters(columnCounts: [ - numColumnsBeforeProfiles, - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db), - Profile.numberOfSelectedColumns(db) - ]) - - return ScopeAdapter.with(ViewModel.self, [ - .contactProfile: adapters[1], - .closedGroupProfileFront: adapters[2], - .closedGroupProfileBack: adapters[3], - .closedGroupProfileBackFallback: adapters[4], - .closedGroupAdminProfile: adapters[5] - ]) - } - } -} diff --git a/SessionMessagingKit/Types/ConversationDataCache.swift b/SessionMessagingKit/Types/ConversationDataCache.swift new file mode 100644 index 0000000000..636813cadb --- /dev/null +++ b/SessionMessagingKit/Types/ConversationDataCache.swift @@ -0,0 +1,402 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public typealias ConversationDataCacheItemRequirements = (Sendable & Equatable & Hashable & Identifiable) + +public struct ConversationDataCache: Sendable, Equatable, Hashable { + public let userSessionId: SessionId + public fileprivate(set) var context: Context + + // MARK: - General + + /// Stores `profileId -> Profile` (`threadId` for contact threads) + public fileprivate(set) var profiles: [String: Profile] = [:] + + // MARK: - Thread Data + + /// Stores `threadId -> SessionThread` + public fileprivate(set) var threads: [String: SessionThread] = [:] + + /// Stores `contactId -> Contact` (`threadId` for contact threads) + public fileprivate(set) var contacts: [String: Contact] = [:] + + /// Stores `threadId -> ClosedGroup` + public fileprivate(set) var groups: [String: ClosedGroup] = [:] + + /// Stores `threadId -> GroupInfo` + public fileprivate(set) var groupInfo: [String: LibSession.GroupInfo] = [:] + + /// Stores `threadId -> members` + public fileprivate(set) var groupMembers: [String: [GroupMember]] = [:] + + /// Stores `threadId -> OpenGroup` + public fileprivate(set) var communities: [String: OpenGroup] = [:] + + /// Stores `openGroup.server -> capabilityVariants` + public fileprivate(set) var communityCapabilities: [String: Set] = [:] + + /// Stores `threadId -> modAdminIds` + public fileprivate(set) var communityModAdminIds: [String: Set] = [:] + + /// Stores `threadId -> isUserModeratorOrAdmin` + public fileprivate(set) var userModeratorOrAdmin: [String: Bool] = [:] + + /// Stores `threadId -> DisappearingMessagesConfig` + public fileprivate(set) var disappearingMessagesConfigurations: [String: DisappearingMessagesConfiguration] = [:] + + /// Stores `threadId -> interactionStats` + public fileprivate(set) var interactionStats: [String: ConversationInfoViewModel.InteractionStats] = [:] + + /// Stores `threadId -> InteractionInfo` (the last interaction info for the thread) + public fileprivate(set) var lastInteractions: [String: ConversationInfoViewModel.InteractionInfo] = [:] + + /// Stores `threadId -> currentUserSessionIds` + public fileprivate(set) var currentUserSessionIds: [String: Set] = [:] + + // MARK: - Message Data + + /// Stores `interactionId -> Interaction` + public fileprivate(set) var interactions: [Int64: Interaction] = [:] + + /// Stores `interactionId -> interactionAttachments` + public fileprivate(set) var attachmentMap: [Int64: Set] = [:] + + /// Stores `attachmentId -> Attachment` + public fileprivate(set) var attachments: [String: Attachment] = [:] + + /// Stores `interactionId -> MaybeUnresolvedQuotedInfo` + public fileprivate(set) var quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = [:] + + /// Stores `url -> previews` + public fileprivate(set) var linkPreviews: [String: Set] = [:] + + /// Stores `interactionId -> reactions` + public fileprivate(set) var reactions: [Int64: [Reaction]] = [:] + + /// Stores `blindedId -> unblindedId` + public fileprivate(set) var unblindedIdMap: [String: String] = [:] + + // MARK: - UI State + + /// Stores `threadIds` for conversations with incoming typing + public fileprivate(set) var incomingTyping: Set = [] + + // MARK: - Initialization + + public init(userSessionId: SessionId, context: Context) { + self.userSessionId = userSessionId + self.context = context + } +} + +// MARK: - Read Operations + +public extension ConversationDataCache { + func profile(for id: String) -> Profile? { profiles[id] } + func thread(for id: String) -> SessionThread? { threads[id] } + func contact(for threadId: String) -> Contact? { contacts[threadId] } + func group(for threadId: String) -> ClosedGroup? { groups[threadId] } + func groupInfo(for threadId: String) -> LibSession.GroupInfo? { groupInfo[threadId] } + func groupMembers(for threadId: String) -> [GroupMember] { (groupMembers[threadId] ?? []) } + func community(for threadId: String) -> OpenGroup? { communities[threadId] } + func communityCapabilities(for server: String) -> Set { + (communityCapabilities[server] ?? []) + } + func communityModAdminIds(for threadId: String) -> Set { (communityModAdminIds[threadId] ?? []) } + func isUserModeratorOrAdmin(in threadId: String) -> Bool { (userModeratorOrAdmin[threadId] ?? false) } + func disappearingMessageConfiguration(for threadId: String) -> DisappearingMessagesConfiguration? { + disappearingMessagesConfigurations[threadId] + } + func interactionStats(for threadId: String) -> ConversationInfoViewModel.InteractionStats? { + interactionStats[threadId] + } + func lastInteraction(for threadId: String) -> ConversationInfoViewModel.InteractionInfo? { + lastInteractions[threadId] + } + func currentUserSessionIds(for threadId: String) -> Set { + return (currentUserSessionIds[threadId] ?? [userSessionId.hexString]) + } + + func interaction(for id: Int64) -> Interaction? { interactions[id] } + func attachment(for id: String) -> Attachment? { attachments[id] } + func attachments(for interactionId: Int64) -> [Attachment] { + guard let interactionAttachments: Set = attachmentMap[interactionId] else { + return [] + } + + return interactionAttachments + .sorted { $0.albumIndex < $1.albumIndex } + .compactMap { attachments[$0.attachmentId] } + } + func interactionAttachments(for interactionId: Int64) -> Set { + (attachmentMap[interactionId] ?? []) + } + func quoteInfo(for interactionId: Int64) -> MessageViewModel.MaybeUnresolvedQuotedInfo? { + quoteMap[interactionId] + } + func linkPreviews(for url: String) -> Set { (linkPreviews[url] ?? []) } + func reactions(for interactionId: Int64) -> [Reaction] { (reactions[interactionId] ?? []) } + func unblindedId(for blindedId: String) -> String? { unblindedIdMap[blindedId] } + func isTyping(in threadId: String) -> Bool { incomingTyping.contains(threadId) } + + func displayNameRetriever(for threadId: String, includeSessionIdSuffixWhenInMessageBody: Bool) -> DisplayNameRetriever { + let currentUserSessionIds: Set = currentUserSessionIds(for: threadId) + + return { sessionId, inMessageBody in + guard !currentUserSessionIds.contains(sessionId) else { + return "you".localized() + } + + return profile(for: sessionId)?.displayName( + includeSessionIdSuffix: (includeSessionIdSuffixWhenInMessageBody && inMessageBody) + ) + } + } +} + +// MARK: - Write Operations + +public extension ConversationDataCache { + mutating func withContext( + source: Context.Source, + requireFullRefresh: Bool = false, + requireAuthMethodFetch: Bool = false, + requiresMessageRequestCountUpdate: Bool = false, + requiresInitialUnreadInteractionInfo: Bool = false, + requireRecentReactionEmojiUpdate: Bool = false + ) { + self.context = Context( + source: source, + requireFullRefresh: requireFullRefresh, + requireAuthMethodFetch: requireAuthMethodFetch, + requiresMessageRequestCountUpdate: requiresMessageRequestCountUpdate, + requiresInitialUnreadInteractionInfo: requiresInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: requireRecentReactionEmojiUpdate + ) + } + + mutating func insert(_ profile: Profile) { + self.profiles[profile.id] = profile + } + + mutating func insert(profiles: [Profile]) { + profiles.forEach { self.profiles[$0.id] = $0 } + } + + mutating func insert(_ thread: SessionThread) { + self.threads[thread.id] = thread + } + + mutating func insert(threads: [SessionThread]) { + threads.forEach { self.threads[$0.id] = $0 } + } + + mutating func insert(_ contact: Contact) { + self.contacts[contact.id] = contact + } + + mutating func insert(contacts: [Contact]) { + contacts.forEach { self.contacts[$0.id] = $0 } + } + + mutating func insert(_ group: ClosedGroup) { + self.groups[group.threadId] = group + } + + mutating func insert(groups: [ClosedGroup]) { + groups.forEach { self.groups[$0.threadId] = $0 } + } + + mutating func insert(_ groupInfo: LibSession.GroupInfo) { + self.groupInfo[groupInfo.groupSessionId] = groupInfo + } + + mutating func insert(groupInfo: [LibSession.GroupInfo]) { + groupInfo.forEach { self.groupInfo[$0.groupSessionId] = $0 } + } + + mutating func insert(groupMembers: [String: [GroupMember]]) { + self.groupMembers.merge(groupMembers) { _, new in new } + } + + mutating func insert(_ community: OpenGroup) { + self.communities[community.threadId] = community + } + + mutating func insert(communities: [OpenGroup]) { + communities.forEach { self.communities[$0.threadId] = $0 } + } + + mutating func insert(communityCapabilities: [String: Set]) { + self.communityCapabilities.merge(communityCapabilities) { _, new in new } + } + + mutating func insert(communityModAdminIds: [String: Set]) { + self.communityModAdminIds.merge(communityModAdminIds) { _, new in new } + } + + mutating func insert(isUserModeratorOrAdmin: Bool, in threadId: String) { + self.userModeratorOrAdmin[threadId] = isUserModeratorOrAdmin + } + + mutating func insert(_ config: DisappearingMessagesConfiguration) { + self.disappearingMessagesConfigurations[config.threadId] = config + } + + mutating func insert(disappearingMessagesConfigurations configs: [DisappearingMessagesConfiguration]) { + configs.forEach { self.disappearingMessagesConfigurations[$0.threadId] = $0 } + } + + mutating func insert(_ stats: ConversationInfoViewModel.InteractionStats) { + self.interactionStats[stats.threadId] = stats + } + + mutating func insert(interactionStats: [ConversationInfoViewModel.InteractionStats]) { + interactionStats.forEach { self.interactionStats[$0.threadId] = $0 } + } + + mutating func insert(_ lastInteraction: ConversationInfoViewModel.InteractionInfo) { + self.lastInteractions[lastInteraction.threadId] = lastInteraction + } + + mutating func insert(lastInteractions: [String: ConversationInfoViewModel.InteractionInfo]) { + self.lastInteractions.merge(lastInteractions) { _, new in new } + } + + mutating func setCurrentUserSessionIds(_ currentUserSessionIds: [String: Set]) { + self.currentUserSessionIds = currentUserSessionIds + } + + mutating func insert(_ interaction: Interaction) { + guard let id: Int64 = interaction.id else { return } + + self.interactions[id] = interaction + } + + mutating func insert(interactions: [Interaction]) { + interactions.forEach { interaction in + guard let id: Int64 = interaction.id else { return } + + self.interactions[id] = interaction + } + } + + mutating func insert(_ attachment: Attachment) { + self.attachments[attachment.id] = attachment + } + + mutating func insert(attachments: [Attachment]) { + attachments.forEach { self.attachments[$0.id] = $0 } + } + + mutating func insert(attachmentMap: [Int64: Set]) { + self.attachmentMap.merge(attachmentMap) { _, new in new } + + /// Remove any empty lists + attachmentMap.forEach { key, value in + guard value.isEmpty else { return } + + self.attachmentMap.removeValue(forKey: key) + } + } + + mutating func insert(quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo]) { + self.quoteMap.merge(quoteMap) { _, new in new } + } + + mutating func insert(linkPreviews: [LinkPreview]) { + linkPreviews.forEach { preview in + self.linkPreviews[preview.url, default: []].insert(preview) + } + } + + mutating func insert(reactions: [Int64: [Reaction]]) { + let sortedReactions: [Int64: [Reaction]] = reactions.mapValues { + $0.sorted { lhs, rhs in lhs.sortId < rhs.sortId } + } + self.reactions.merge(sortedReactions) { _, new in new } + + /// Remove any empty lists + reactions.forEach { key, value in + guard value.isEmpty else { return } + + self.reactions.removeValue(forKey: key) + } + } + + mutating func insert(unblindedIdMap: [String: String]) { + self.unblindedIdMap.merge(unblindedIdMap) { _, new in new } + } + + mutating func setTyping(_ isTyping: Bool, in threadId: String) { + if isTyping { + self.incomingTyping.insert(threadId) + } else { + self.incomingTyping.remove(threadId) + } + } + + mutating func remove(threadIds: Set) { + threadIds.forEach { threadId in + self.threads.removeValue(forKey: threadId) + self.contacts.removeValue(forKey: threadId) + self.groups.removeValue(forKey: threadId) + self.groupInfo.removeValue(forKey: threadId) + self.groupMembers.removeValue(forKey: threadId) + self.communities.removeValue(forKey: threadId) + self.communityModAdminIds.removeValue(forKey: threadId) + self.userModeratorOrAdmin.removeValue(forKey: threadId) + self.disappearingMessagesConfigurations.removeValue(forKey: threadId) + self.interactionStats.removeValue(forKey: threadId) + self.incomingTyping.remove(threadId) + self.lastInteractions.removeValue(forKey: threadId) + + let interactions: [Interaction] = Array(self.interactions.values) + interactions.forEach { interaction in + guard + let interactionId: Int64 = interaction.id, + interaction.threadId == threadId + else { return } + + self.interactions.removeValue(forKey: interactionId) + self.attachmentMap[interactionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) } + self.attachmentMap.removeValue(forKey: interactionId) + } + } + } + + mutating func remove(interactionIds: Set) { + interactionIds.forEach { id in + self.interactions.removeValue(forKey: id) + self.reactions.removeValue(forKey: id) + self.attachmentMap[id]?.forEach { + self.attachments.removeValue(forKey: $0.attachmentId) + } + self.attachmentMap.removeValue(forKey: id) + } + } + + mutating func removeAttachmentMap(for interactionId: Int64) { + self.attachmentMap.removeValue(forKey: interactionId) + } +} + +// MARK: - Convenience + +public extension ConversationDataCache { + func contactDisplayName(for threadId: String) -> String { + /// We expect a non-nullable string so if it's invalid just return an empty string + guard + let thread: SessionThread = thread(for: threadId), + thread.variant == .contact + else { return "" } + + let profile: Profile = (profile(for: thread.id) ?? Profile.defaultFor(thread.id)) + + return profile.displayName() + } +} diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift new file mode 100644 index 0000000000..1909ddf481 --- /dev/null +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -0,0 +1,1014 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public enum ConversationDataHelper {} + +public extension ConversationDataCache { + struct Context: Sendable, Equatable, Hashable { + public enum Source: Sendable, Equatable, Hashable { + case conversationList + case messageList(threadId: String) + case searchResults + } + + let source: Source + let requireFullRefresh: Bool + let requireAuthMethodFetch: Bool + let requiresMessageRequestCountUpdate: Bool + let requiresInitialUnreadInteractionInfo: Bool + let requireRecentReactionEmojiUpdate: Bool + + var isConversationList: Bool { + switch source { + case .conversationList: return true + default: return false + } + } + + var isMessageList: Bool { + switch source { + case .messageList: return true + default: return false + } + } + + // MARK: - Initialization + + public init( + source: Source, + requireFullRefresh: Bool, + requireAuthMethodFetch: Bool, + requiresMessageRequestCountUpdate: Bool, + requiresInitialUnreadInteractionInfo: Bool, + requireRecentReactionEmojiUpdate: Bool + ) { + self.source = source + self.requireFullRefresh = requireFullRefresh + self.requireAuthMethodFetch = requireAuthMethodFetch + self.requiresMessageRequestCountUpdate = requiresMessageRequestCountUpdate + self.requiresInitialUnreadInteractionInfo = requiresInitialUnreadInteractionInfo + self.requireRecentReactionEmojiUpdate = requireRecentReactionEmojiUpdate + } + + // MARK: - Functions + + func insertedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { + switch source { + case .searchResults: return [] + case .conversationList: return (requirements.insertedThreadIds as? Set ?? []) + case .messageList: return (requirements.insertedInteractionIds as? Set ?? []) + } + } + + func deletedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { + switch source { + case .searchResults: return [] + case .conversationList: return (requirements.deletedThreadIds as? Set ?? []) + case .messageList: return (requirements.deletedInteractionIds as? Set ?? []) + } + } + } +} + +public extension ConversationDataHelper { + static func determineFetchRequirements( + for changes: EventChangeset, + currentCache: ConversationDataCache, + itemCache: [Item.ID: Item], + loadPageEvent: LoadPageEvent? + ) -> FetchRequirements { + var requirements: FetchRequirements = FetchRequirements( + requireAuthMethodFetch: currentCache.context.requireAuthMethodFetch, + requiresMessageRequestCountUpdate: currentCache.context.requiresMessageRequestCountUpdate, + requiresInitialUnreadInteractionInfo: currentCache.context.requiresInitialUnreadInteractionInfo, + requireRecentReactionEmojiUpdate: ( + currentCache.context.requireRecentReactionEmojiUpdate || + changes.contains(.recentReactionsUpdated) + ) + ) + + /// Validate we have the bear minimum data for the source + switch currentCache.context.source { + case .conversationList, .searchResults: break + case .messageList(let threadId): + /// On the message list if we don't currently have the thread cached then we need to fetch it + guard currentCache.thread(for: threadId) == nil else { break } + + requirements.threadIdsNeedingFetch.insert(threadId) + } + + /// If we need a full fetch then we need to fill the "idsNeedingFetch" sets with info from the current cache + if currentCache.context.requireFullRefresh { + requirements.threadIdsNeedingFetch.insert(contentsOf: Set(currentCache.threads.keys)) + requirements.interactionIdsNeedingFetch.insert(contentsOf: Set(currentCache.interactions.keys)) + + switch currentCache.context.source { + case .searchResults: break + case .conversationList: + requirements.threadIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) + + case .messageList: + requirements.interactionIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) + } + } + + /// Handle explicit events which may require additional data to be fetched + changes.databaseEvents.forEach { event in + switch (event.key.generic, event.value) { + case (GenericObservableKey(.messageRequestAccepted), let threadId as String): + requirements.threadIdsNeedingFetch.insert(threadId) + + case (_, is ConversationEvent): + handleConversationEvent( + event, + cache: currentCache, + itemCache: itemCache, + requirements: &requirements + ) + + case (_, is MessageEvent): + handleMessageEvent( + event, + cache: currentCache, + requirements: &requirements + ) + + /// Blocking and unblocking contacts should result in the conversation being removed/added to the conversation list + /// + /// **Note:** This is generally observed via `anyContactBlockedStatusChanged` + case (_, let contactEvent as ContactEvent): + if case .isBlocked(true) = contactEvent.change { + requirements.deletedThreadIds.insert(contactEvent.id) + } + else if case .isBlocked(false) = contactEvent.change { + requirements.insertedThreadIds.insert(contactEvent.id) + } + + case (_, let groupMemberEvent as GroupMemberEvent): + requirements.groupIdsNeedingMemberFetch.insert(groupMemberEvent.threadId) + + case (_, let profileEvent as ProfileEvent): + /// Only fetch if not already cached + if currentCache.profile(for: profileEvent.id) == nil { + requirements.profileIdsNeedingFetch.insert(profileEvent.id) + } + + case (_, let attachmentEvent as AttachmentEvent): + requirements.attachmentIdsNeedingFetch.insert(attachmentEvent.id) + + case (_, let reactionEvent as ReactionEvent): + requirements.interactionIdsNeedingReactionUpdates.insert(reactionEvent.messageId) + + default: break + } + } + + /// Handle any events which require a change to the message request count + requirements.requiresMessageRequestCountUpdate = changes.databaseEvents.contains { event in + switch event.key { + case .messageRequestUnreadMessageReceived, .messageRequestAccepted, .messageRequestDeleted, + .messageRequestMessageRead: + return true + + default: return false + } + } + + /// Handle page loading events based on view context + requirements.needsPageLoad = { + guard !currentCache.context.requireFullRefresh else { + return true /// Need to refetch the paged data in case the sorting changed + } + + let hasDirectPagedDataChange: Bool = ( + loadPageEvent != nil || + !currentCache.context.insertedItemIds(requirements, as: Item.ID.self).isEmpty || + !currentCache.context.deletedItemIds(requirements, as: Item.ID.self).isEmpty + ) + + guard !hasDirectPagedDataChange else { return true } + + switch currentCache.context.source { + case .messageList, .searchResults: return false + case .conversationList: + /// On the conversation list if a new message is created in any conversation then we need to reload the paged + /// data as it means the conversation order likely changed + guard changes.contains(.anyMessageCreatedInAnyConversation) else { return false } + + return true + } + }() + + return requirements + } + + static func applyNonDatabaseEvents( + _ changes: EventChangeset, + currentCache: ConversationDataCache, + using dependencies: Dependencies + ) async -> ConversationDataCache { + var updatedCache: ConversationDataCache = currentCache + + /// We sacrifice a little memory and performance here to simplify the logic greatly, always refresh the `currentUserSessionIds` + /// and `communityModAdminIds` to match the latest data stored in the `CommunityManager` + let communityServers: [String: CommunityManager.Server] = await dependencies[singleton: .communityManager] + .serversByThreadId() + updatedCache.setCurrentUserSessionIds(communityServers.mapValues { $0.currentUserSessionIds }) + updatedCache.insert( + communityModAdminIds: communityServers.values.reduce(into: [:]) { result, next in + for room in next.rooms.values { + result[OpenGroup.idFor(roomToken: room.token, server: next.server)] = CommunityManager.allModeratorsAndAdmins( + room: room, + includingHidden: true + ) + } + } + ) + + /// General Conversation Changes + changes.forEach(.conversationUpdated, as: ConversationEvent.self) { event in + switch (event.variant, event.change) { + case (.group, .displayName(let name)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(name: .set(to: name))) + + case (.community, .displayName(let name)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(name: .set(to: name))) + + case (.group, .description(let description)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(groupDescription: .set(to: description))) + + case (.community, .description(let description)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(roomDescription: .set(to: description))) + + case (.group, .displayPictureUrl(let url)): + guard let group: ClosedGroup = updatedCache.group(for: event.id) else { return } + + updatedCache.insert(group.with(displayPictureUrl: .set(to: url))) + + case (.community, .displayPictureUrl(let url)): + guard let community: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert(community.with(displayPictureOriginalUrl: .set(to: url))) + + case (_, .pinnedPriority(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(pinnedPriority: .set(to: value))) + + case (_, .shouldBeVisible(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(shouldBeVisible: .set(to: value))) + + case (_, .mutedUntilTimestamp(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(mutedUntilTimestamp: .set(to: value))) + + case (_, .onlyNotifyForMentions(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(onlyNotifyForMentions: .set(to: value))) + + case (_, .markedAsUnread(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(markedAsUnread: .set(to: value))) + + case (_, .draft(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(messageDraft: .set(to: value))) + + case (_, .disappearingMessageConfiguration(let value)): + guard let value: DisappearingMessagesConfiguration = value else { return } + + updatedCache.insert(disappearingMessagesConfigurations: [value]) + + /// These need to be handled via a database query + case (_, .unreadCount), (_, .none): return + + /// These events can be ignored as they will be handled via profile changes + case (.contact, .displayName), (.contact, .displayPictureUrl): return + + /// These combinations are not supported so can be ignored + case (.contact, .description), (.legacyGroup, _): return + } + } + + /// Profile changes + changes.forEach(.profile, as: ProfileEvent.self) { event in + /// This profile (somehow) isn't in the cache so ignore event updates (it'll be fetched from the database when we hit that query) + guard var profile: Profile = updatedCache.profile(for: event.id) else { return } + + switch event.change { + case .name(let name): profile = profile.with(name: name) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) + case .proStatus(_, let features, let proExpiryUnixTimestampMs, let proGenIndexHashHex): + /// **Note:** The final view model initialiser is responsible for mocking out or removing `proFeatures` + /// based on the dev settings + profile = profile.with( + proFeatures: .set(to: features), + proExpiryUnixTimestampMs: .set(to: proExpiryUnixTimestampMs), + proGenIndexHashHex: .set(to: proGenIndexHashHex) + ) + } + + updatedCache.insert(profile) + } + + /// Contact Changes + changes.forEach(.contact, as: ContactEvent.self) { event in + switch event.change { + case .isTrusted(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isTrusted: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .isApproved(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isApproved: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .isBlocked(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + isBlocked: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .didApproveMe(let value): + guard let contact: Contact = updatedCache.contact(for: event.id) else { return } + + updatedCache.insert(contact.with( + didApproveMe: .set(to: value), + currentUserSessionId: currentCache.userSessionId + )) + + case .unblinded: break /// Needs custom handling + } + } + + /// Group Changes + changes.forEach(.groupInfo, as: LibSession.GroupInfo.self) { info in + updatedCache.insert(info) + } + + changes.forEach(.groupMemberUpdated, as: GroupMemberEvent.self) { event in + switch event.change { + case .none: break + case .role(let role, let status): + if event.profileId == currentCache.userSessionId.hexString { + updatedCache.insert(isUserModeratorOrAdmin: (role == .admin), in: event.threadId) + } + + var updatedMembers: [GroupMember] = updatedCache.groupMembers(for: event.threadId) + + if let memberIndex: Int = updatedMembers.firstIndex(where: { $0.profileId == event.profileId }) { + updatedMembers[memberIndex] = GroupMember( + groupId: event.threadId, + profileId: event.profileId, + role: role, + roleStatus: status, + isHidden: updatedMembers[memberIndex].isHidden + ) + updatedCache.insert(groupMembers: [event.threadId: updatedMembers]) + } + } + } + + /// Community changes + changes.forEach(.communityUpdated, as: CommunityEvent.self) { event in + switch event.change { + case .capabilities(let capabilities): + updatedCache.insert(communityCapabilities: [event.id: Set(capabilities)]) + + case .permissions(let read, let write, let upload): + guard let openGroup: OpenGroup = updatedCache.community(for: event.id) else { return } + + updatedCache.insert( + openGroup.with( + permissions: .set(to: OpenGroup.Permissions( + read: read, + write: write, + upload: upload + )) + ) + ) + + case .role(let moderator, let admin, let hiddenModerator, let hiddenAdmin): + updatedCache.insert( + isUserModeratorOrAdmin: (moderator || admin || hiddenModerator || hiddenAdmin), + in: event.id + ) + + case .moderatorsAndAdmins(let admins, let hiddenAdmins, let moderators, let hiddenModerators): + var combined: [String] = admins + combined.insert(contentsOf: hiddenAdmins, at: 0) + combined.insert(contentsOf: moderators, at: 0) + combined.insert(contentsOf: hiddenModerators, at: 0) + + let modAdminIds: Set = Set(combined) + updatedCache.insert(communityModAdminIds: [event.id: modAdminIds]) + updatedCache.insert( + isUserModeratorOrAdmin: !modAdminIds + .isDisjoint(with: updatedCache.currentUserSessionIds(for: event.id)), + in: event.id + ) + + /// No need to do anything for these changes + case .receivedInitialMessages: break + } + } + + /// General unblinding handling + changes.forEach(.anyContactUnblinded, as: ContactEvent.self) { event in + switch event.change { + case .unblinded(let blindedId, let unblindedId): + updatedCache.insert(unblindedIdMap: [blindedId: unblindedId]) + + default: break + } + } + + /// Typing indicators + changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in + switch event.change { + case .started: updatedCache.setTyping(true, in: event.threadId) + case .stopped: updatedCache.setTyping(false, in: event.threadId) + } + } + + return updatedCache + } + + static func fetchFromDatabase( + _ db: ObservingDatabase, + requirements: FetchRequirements, + currentCache: ConversationDataCache, + loadResult: PagedData.LoadResult, + loadPageEvent: LoadPageEvent?, + using dependencies: Dependencies + ) throws -> (loadResult: PagedData.LoadResult, cache: ConversationDataCache) { + guard requirements.needsAnyFetch else { + return (loadResult, currentCache) + } + + var updatedLoadResult: PagedData.LoadResult = loadResult + var updatedCache: ConversationDataCache = currentCache + var updatedRequirements: FetchRequirements = requirements.resettingExternalFetchFlags() + + /// Handle page loads first + if updatedRequirements.needsPageLoad { + let target: PagedData.Target + + switch (loadPageEvent?.target(with: loadResult), currentCache.context.source) { + case (.some(let explicitTarget), _): target = explicitTarget + case (.none, .searchResults): target = .newItems(insertedIds: [], deletedIds: []) + case (.none, .conversationList): + target = .reloadCurrent( + insertedIds: currentCache.context.insertedItemIds(updatedRequirements, as: ID.self), + deletedIds: currentCache.context.deletedItemIds(updatedRequirements, as: ID.self) + ) + + case (.none, .messageList): + target = .newItems( + insertedIds: currentCache.context.insertedItemIds(updatedRequirements, as: ID.self), + deletedIds: currentCache.context.deletedItemIds(updatedRequirements, as: ID.self) + ) + } + + updatedLoadResult = try loadResult.load(db, target: target) + updatedRequirements.needsPageLoad = false + } + + switch currentCache.context.source { + case .searchResults: break + case .conversationList: + if let newIds: [String] = updatedLoadResult.newIds as? [String], !newIds.isEmpty { + updatedRequirements.threadIdsNeedingFetch.insert(contentsOf: Set(newIds)) + updatedRequirements.threadIdsNeedingInteractionStats.insert(contentsOf: Set(newIds)) + } + + case .messageList: + if let newIds: [Int64] = updatedLoadResult.newIds as? [Int64], !newIds.isEmpty { + updatedRequirements.interactionIdsNeedingFetch.insert(contentsOf: Set(newIds)) + } + } + + /// Now that we've finished the page load we can clear out the "insertedIds" sets (should only be used for the above) + updatedRequirements.insertedThreadIds.removeAll() + updatedRequirements.insertedInteractionIds.removeAll() + + /// Loop through the data until we no longer need to fetch anything + /// + /// **Note:** The code below _should_ only run once but it's dependant on being run in a specific order (as fetching one + /// type can result in the need to fetch more data for other types). In order to avoid bugs being introduced in the future due + /// to re-ordering the below we instead loop until there is nothing left to fetch. + var loopCounter: Int = 0 + + while updatedRequirements.needsAnyFetch { + /// Fetch any required records and update the caches + if !updatedRequirements.threadIdsNeedingFetch.isEmpty { + let threads: [SessionThread] = try SessionThread + .filter(ids: updatedRequirements.threadIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(threads: threads) + updatedRequirements.threadIdsNeedingFetch.removeAll() + + /// Fetch the disappearing messages config for the conversation + let disappearingConfigs: [DisappearingMessagesConfiguration] = try DisappearingMessagesConfiguration + .filter(ids: threads.map { $0.id }) + .fetchAll(db) + updatedCache.insert(disappearingMessagesConfigurations: disappearingConfigs) + + /// Fetch any associated data that isn't already cached + threads.forEach { thread in + switch thread.variant { + case .contact: + if updatedCache.profile(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.profileIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.contact(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.contactIdsNeedingFetch.insert(thread.id) + } + + case .group, .legacyGroup: + if updatedCache.group(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.groupMembers(for: thread.id).isEmpty || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingMemberFetch.insert(thread.id) + } + + case .community: + if updatedCache.community(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.communityIdsNeedingFetch.insert(thread.id) + } + } + } + } + + if !updatedRequirements.threadIdsNeedingInteractionStats.isEmpty { + let stats: [ConversationInfoViewModel.InteractionStats] = try ConversationInfoViewModel.InteractionStats + .request(for: updatedRequirements.threadIdsNeedingInteractionStats) + .fetchAll(db) + updatedCache.insert(interactionStats: stats) + updatedRequirements.interactionIdsNeedingFetch.insert( + contentsOf: Set(stats.map { $0.latestInteractionId }) + ) + updatedRequirements.threadIdsNeedingInteractionStats.removeAll() + } + + if !updatedRequirements.interactionIdsNeedingFetch.isEmpty { + /// If the source is `messageList` then before we fetch the interactions we need to get the ids of any quoted interactions + /// + /// **Note:** We may not be able to find the quoted interaction (hence the `Int64?` but would still want to render + /// the message as a quote) + switch currentCache.context.source { + case .conversationList, .searchResults: break + case .messageList(let threadId): + let quoteInteractionIdResults: Set> = try MessageViewModel + .quotedInteractionIds( + for: updatedRequirements.interactionIdsNeedingFetch, + currentUserSessionIds: updatedCache.currentUserSessionIds(for: threadId) + ) + .fetchSet(db) + + updatedCache.insert(quoteMap: quoteInteractionIdResults.reduce(into: [:]) { result, next in + result[next.first] = MessageViewModel.MaybeUnresolvedQuotedInfo( + foundQuotedInteractionId: next.second + ) + }) + updatedRequirements.interactionIdsNeedingFetch.insert( + contentsOf: Set(quoteInteractionIdResults.compactMap { $0.second }) + ) + + /// We want to just refetch all reactions (handling individual reaction events, especially with "pending" + /// reactions in SOGS, will likely result in bugs) + updatedRequirements.interactionIdsNeedingReactionUpdates.insert( + contentsOf: updatedRequirements.interactionIdsNeedingFetch + ) + } + + /// Now fetch the interactions + let interactions: [Interaction] = try Interaction + .filter(ids: updatedRequirements.interactionIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(interactions: interactions) + updatedRequirements.interactionIdsNeedingFetch.removeAll() + + let attachmentMap: [Int64: Set] = try InteractionAttachment + .filter(interactions.map { $0.id }.contains(InteractionAttachment.Columns.interactionId)) + .fetchAll(db) + .grouped(by: \.interactionId) + .mapValues { Set($0) } + updatedCache.insert(attachmentMap: attachmentMap) + + /// In the `conversationList` we only care about the first attachment and the total number of attachments (for the + /// snippet) so no need to fetch others + let targetAttachmentIds: Set = Set(attachmentMap.values + .flatMap { $0 } + .filter { interactionAttachment in + switch currentCache.context.source { + case .conversationList, .searchResults: return (interactionAttachment.albumIndex == 0) + case .messageList: return true + } + } + .map { $0.attachmentId }) + updatedRequirements.attachmentIdsNeedingFetch.insert(contentsOf: targetAttachmentIds) + + /// Fetch any link previews needed + let linkPreviewLookupInfo: [(url: String, timestamp: Int64)] = interactions.compactMap { + guard let url: String = $0.linkPreviewUrl else { return nil } + + return (url, $0.timestampMs) + } + + if !linkPreviewLookupInfo.isEmpty { + let urls: [String] = linkPreviewLookupInfo.map(\.url) + let minTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).min() ?? 0) + let maxTimestampMs: Int64 = (linkPreviewLookupInfo.map(\.timestamp).max() ?? Int64.max) + let finalMinTimestamp: TimeInterval = (TimeInterval(minTimestampMs / 1000) - LinkPreview.timstampResolution) + let finalMaxTimestamp: TimeInterval = (TimeInterval(maxTimestampMs / 1000) + LinkPreview.timstampResolution) + + let linkPreviews: [LinkPreview] = try LinkPreview + .filter(urls.contains(LinkPreview.Columns.url)) + .filter(LinkPreview.Columns.timestamp > finalMinTimestamp) + .filter(LinkPreview.Columns.timestamp < finalMaxTimestamp) + .fetchAll(db) + updatedCache.insert(linkPreviews: linkPreviews) + updatedRequirements.attachmentIdsNeedingFetch.insert( + contentsOf: Set(linkPreviews.compactMap { $0.attachmentId }) + ) + } + + /// If the interactions contain any profiles that we don't have cached then we need to fetch those as well + interactions.forEach { interaction in + if updatedCache.profile(for: interaction.authorId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(interaction.authorId) + } + + MentionUtilities.allPubkeys(in: (interaction.body ?? "")).forEach { mentionedId in + if updatedCache.profile(for: mentionedId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(mentionedId) + } + } + } + } + + if !updatedRequirements.attachmentIdsNeedingFetch.isEmpty { + let attachments: [Attachment] = try Attachment + .filter(ids: updatedRequirements.attachmentIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(attachments: attachments) + updatedRequirements.attachmentIdsNeedingFetch.removeAll() + } + + if !updatedRequirements.interactionIdsNeedingReactionUpdates.isEmpty { + let reactions: [Int64: [Reaction]] = try Reaction + .filter(updatedRequirements.interactionIdsNeedingReactionUpdates.contains(Reaction.Columns.interactionId)) + .fetchAll(db) + .grouped(by: \.interactionId) + updatedCache.insert(reactions: reactions) + updatedRequirements.interactionIdsNeedingReactionUpdates.removeAll() + } + + if !updatedRequirements.contactIdsNeedingFetch.isEmpty { + let contacts: [Contact] = try Contact + .filter(ids: updatedRequirements.contactIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(contacts: contacts) + updatedRequirements.contactIdsNeedingFetch.removeAll() + + contacts.forEach { contact in + if updatedCache.profile(for: contact.id) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(contact.id) + } + } + } + + if !updatedRequirements.groupIdsNeedingFetch.isEmpty { + let groups: [ClosedGroup] = try ClosedGroup + .filter(ids: updatedRequirements.groupIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(groups: groups) + updatedRequirements.groupIdsNeedingFetch.removeAll() + + updatedRequirements.groupIdsNeedingMemberFetch.insert(contentsOf: Set(groups.map { $0.threadId })) + } + + if !updatedRequirements.groupIdsNeedingMemberFetch.isEmpty { + let groupMembers: [GroupMember] = try GroupMember + .filter(updatedRequirements.groupIdsNeedingMemberFetch.contains(GroupMember.Columns.groupId)) + .fetchAll(db) + updatedCache.insert(groupMembers: groupMembers.grouped(by: \.groupId)) + updatedRequirements.groupIdsNeedingMemberFetch.removeAll() + + groupMembers.forEach { member in + if updatedCache.profile(for: member.profileId) == nil { + updatedRequirements.profileIdsNeedingFetch.insert(member.profileId) + } + } + } + + if !updatedRequirements.communityIdsNeedingFetch.isEmpty { + let communities: [OpenGroup] = try OpenGroup + .filter(ids: updatedRequirements.communityIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(communities: communities) + updatedRequirements.communityIdsNeedingFetch.removeAll() + + /// Also need to fetch capabilities if we don't have them cached for this server + let communityServersNeedingCapabilityFetch: Set = Set(communities.compactMap { openGroup in + guard updatedCache.communityCapabilities(for: openGroup.server).isEmpty else { return nil } + + return openGroup.server + }) + + if !communityServersNeedingCapabilityFetch.isEmpty { + let capabilities: [Capability] = try Capability + .filter(communityServersNeedingCapabilityFetch.contains(Capability.Columns.openGroupServer)) + .fetchAll(db) + + updatedCache.insert( + communityCapabilities: capabilities + .grouped(by: \.openGroupServer) + .mapValues { capabilities in Set(capabilities.map { $0.variant }) } + ) + } + } + + if !updatedRequirements.profileIdsNeedingFetch.isEmpty { + let profiles: [Profile] = try Profile + .filter(ids: updatedRequirements.profileIdsNeedingFetch) + .fetchAll(db) + updatedCache.insert(profiles: profiles) + updatedRequirements.profileIdsNeedingFetch.removeAll() + + /// If the source is `messageList` and we have blinded ids then we want to update the `unblindedIdMap` so + /// that we can show a users unblinded profile in the profile modal if possible + let blindedIds: Set = Set(profiles.map { $0.id } + .filter { SessionId.Prefix.isCommunityBlinded($0) }) + + if !blindedIds.isEmpty { + switch currentCache.context.source { + case .conversationList, .searchResults: break + case .messageList: + let blindedIdMap: [String: String] = try BlindedIdLookup + .filter(ids: blindedIds) + .filter(BlindedIdLookup.Columns.sessionId != nil) + .fetchAll(db) + .reduce(into: [:]) { result, next in result[next.blindedId] = next.sessionId } + + updatedCache.insert(unblindedIdMap: blindedIdMap) + } + } + } + + loopCounter += 1 + + guard loopCounter < 10 else { + Log.critical("[ConversationDataHelper] We ended up looping 10 times trying to update the cache, something went wrong: \(updatedRequirements).") + break + } + } + + /// Remove any values which are no longer needed + updatedCache.remove(threadIds: updatedRequirements.deletedThreadIds) + updatedCache.remove(interactionIds: updatedRequirements.deletedInteractionIds) + + return (updatedLoadResult, updatedCache) + } + + /// This function currently assumes that it will be run after the `fetchFromDatabase` function - we may have to rework in the future + /// to support additional data being sourced from `libSession` (potentially calling this both before and after `fetchFromDatabase`) + static func fetchFromLibSession( + requirements: FetchRequirements, + cache: ConversationDataCache, + using dependencies: Dependencies + ) throws -> ConversationDataCache { + var updatedCache: ConversationDataCache = cache + let groupInfoIdsNeedingFetch: Set = Set(cache.groups.keys) + .filter { cache.groupInfo(for: $0) == nil } + + if !groupInfoIdsNeedingFetch.isEmpty { + let groupInfo: [LibSession.GroupInfo?] = dependencies.mutate(cache: .libSession) { cache in + cache.groupInfo(for: groupInfoIdsNeedingFetch) + } + + updatedCache.insert(groupInfo: groupInfo.compactMap { $0 }) + } + + return updatedCache + } +} + +// MARK: - Convenience + +public extension ConversationDataHelper { + static func fetchFromDatabase( + _ db: ObservingDatabase, + requirements: FetchRequirements, + currentCache: ConversationDataCache, + using dependencies: Dependencies + ) throws -> ConversationDataCache { + return try fetchFromDatabase( + db, + requirements: requirements, + currentCache: currentCache, + loadResult: PagedData.LoadResult.createInvalid(), + loadPageEvent: nil, + using: dependencies + ).cache + } +} + +// MARK: - Specific Event Handling + +private extension ConversationDataHelper { + static func handleConversationEvent( + _ event: ObservedEvent, + cache: ConversationDataCache, + itemCache: [Item.ID: Item], + requirements: inout FetchRequirements + ) { + guard let conversationEvent: ConversationEvent = event.value as? ConversationEvent else { return } + + switch (event.key.generic, conversationEvent.change) { + case (.conversationCreated, _): requirements.insertedThreadIds.insert(conversationEvent.id) + case (.conversationDeleted, _): requirements.deletedThreadIds.insert(conversationEvent.id) + + case (_, .disappearingMessageConfiguration): + guard cache.context.isMessageList else { return } + + /// Since we cache whether a messages disappearing message config can be followed we + /// need to update the value if the disappearing message config on the conversation changes + itemCache.forEach { _, item in + guard + let messageViewModel: MessageViewModel = item as? MessageViewModel, + messageViewModel.canFollowDisappearingMessagesSetting + else { return } + + requirements.interactionIdsNeedingFetch.insert(messageViewModel.id) + } + + default: break + } + } + + static func handleMessageEvent( + _ event: ObservedEvent, + cache: ConversationDataCache, + requirements: inout FetchRequirements + ) { + guard + let messageEvent: MessageEvent = event.value as? MessageEvent, + let interactionId: Int64 = messageEvent.id + else { return } + + switch event.key.generic { + case .messageCreated: requirements.insertedInteractionIds.insert(interactionId) + case .messageUpdated: requirements.interactionIdsNeedingFetch.insert(interactionId) + case .messageDeleted: requirements.deletedInteractionIds.insert(interactionId) + case GenericObservableKey(.anyMessageCreatedInAnyConversation): + requirements.insertedInteractionIds.insert(interactionId) + + /// If we don't currently have the thread in the cache then it's likely a thread from a page which hasn't been fetched + /// yet, we now need to fetch it in case in now belongs in the current page + if cache.thread(for: messageEvent.threadId) == nil { + requirements.insertedThreadIds.insert(messageEvent.threadId) + } + + default: break + } + + if cache.context.isConversationList { + /// Any message event means we need to refetch interaction stats and latest message + requirements.threadIdsNeedingInteractionStats.insert(messageEvent.threadId) + } + } +} + +// MARK: - FetchRequirements + +public extension ConversationDataHelper { + struct FetchRequirements { + public var requireAuthMethodFetch: Bool + public var requiresMessageRequestCountUpdate: Bool + public var requiresInitialUnreadInteractionInfo: Bool + public var requireRecentReactionEmojiUpdate: Bool + fileprivate var needsPageLoad: Bool + + fileprivate var insertedThreadIds: Set + fileprivate var deletedThreadIds: Set + fileprivate var insertedInteractionIds: Set + fileprivate var deletedInteractionIds: Set + + fileprivate var threadIdsNeedingFetch: Set + fileprivate var threadIdsNeedingInteractionStats: Set + fileprivate var contactIdsNeedingFetch: Set + fileprivate var groupIdsNeedingFetch: Set + fileprivate var groupIdsNeedingMemberFetch: Set + fileprivate var communityIdsNeedingFetch: Set + fileprivate var profileIdsNeedingFetch: Set + fileprivate var interactionIdsNeedingFetch: Set + fileprivate var interactionIdsNeedingReactionUpdates: Set + fileprivate var attachmentIdsNeedingFetch: Set + + public var needsAnyFetch: Bool { + requireAuthMethodFetch || + requiresMessageRequestCountUpdate || + requiresInitialUnreadInteractionInfo || + requireRecentReactionEmojiUpdate || + needsPageLoad || + !insertedThreadIds.isEmpty || + !insertedInteractionIds.isEmpty || + + !threadIdsNeedingFetch.isEmpty || + !threadIdsNeedingInteractionStats.isEmpty || + !contactIdsNeedingFetch.isEmpty || + !groupIdsNeedingFetch.isEmpty || + !groupIdsNeedingMemberFetch.isEmpty || + !communityIdsNeedingFetch.isEmpty || + !profileIdsNeedingFetch.isEmpty || + !interactionIdsNeedingFetch.isEmpty || + !interactionIdsNeedingReactionUpdates.isEmpty || + !attachmentIdsNeedingFetch.isEmpty + } + + public init( + requireAuthMethodFetch: Bool, + requiresMessageRequestCountUpdate: Bool, + requiresInitialUnreadInteractionInfo: Bool, + requireRecentReactionEmojiUpdate: Bool, + needsPageLoad: Bool = false, + insertedThreadIds: Set = [], + deletedThreadIds: Set = [], + insertedInteractionIds: Set = [], + deletedInteractionIds: Set = [], + threadIdsNeedingFetch: Set = [], + threadIdsNeedingInteractionStats: Set = [], + contactIdsNeedingFetch: Set = [], + groupIdsNeedingFetch: Set = [], + groupIdsNeedingMemberFetch: Set = [], + communityIdsNeedingFetch: Set = [], + profileIdsNeedingFetch: Set = [], + interactionIdsNeedingFetch: Set = [], + interactionIdsNeedingReactionUpdates: Set = [], + attachmentIdsNeedingFetch: Set = [] + ) { + self.requireAuthMethodFetch = requireAuthMethodFetch + self.requiresMessageRequestCountUpdate = requiresMessageRequestCountUpdate + self.requiresInitialUnreadInteractionInfo = requiresInitialUnreadInteractionInfo + self.requireRecentReactionEmojiUpdate = requireRecentReactionEmojiUpdate + self.needsPageLoad = needsPageLoad + self.insertedThreadIds = insertedThreadIds + self.deletedThreadIds = deletedThreadIds + self.insertedInteractionIds = insertedInteractionIds + self.deletedInteractionIds = deletedInteractionIds + self.threadIdsNeedingFetch = threadIdsNeedingFetch + self.threadIdsNeedingInteractionStats = threadIdsNeedingInteractionStats + self.contactIdsNeedingFetch = contactIdsNeedingFetch + self.groupIdsNeedingFetch = groupIdsNeedingFetch + self.groupIdsNeedingMemberFetch = groupIdsNeedingMemberFetch + self.communityIdsNeedingFetch = communityIdsNeedingFetch + self.profileIdsNeedingFetch = profileIdsNeedingFetch + self.interactionIdsNeedingFetch = interactionIdsNeedingFetch + self.interactionIdsNeedingReactionUpdates = interactionIdsNeedingReactionUpdates + self.attachmentIdsNeedingFetch = attachmentIdsNeedingFetch + } + + public func resettingExternalFetchFlags() -> FetchRequirements { + var result: FetchRequirements = self + result.requireAuthMethodFetch = false + result.requiresMessageRequestCountUpdate = false + result.requiresInitialUnreadInteractionInfo = false + result.requireRecentReactionEmojiUpdate = false + + return result + } + } +} diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift new file mode 100644 index 0000000000..accdccadfc --- /dev/null +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -0,0 +1,899 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import GRDB +import DifferenceKit +import SessionUIKit +import SessionUtilitiesKit + +/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewModel` and the +/// `GlobalSearchViewController`, it should be populated via the `ConversationDataHelper` and should be tied to a screen +/// using the `ObservationBuilder` in order to properly populate it's content +public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Hashable, Identifiable, Differentiable { + public typealias PagedDataType = SessionThread + + public var differenceIdentifier: String { id } + + public let id: String + public let variant: SessionThread.Variant + public let displayName: String + public let displayPictureUrl: String? + public let conversationDescription: String? + public let creationDateTimestamp: TimeInterval + public let shouldBeVisible: Bool + public let pinnedPriority: Int32 + + public let isDraft: Bool + public let isNoteToSelf: Bool + public let isBlocked: Bool + + /// This flag indicates whether the thread is an outgoing message request + public let isMessageRequest: Bool + + /// This flag indicates whether the thread is an incoming message request + public let requiresApproval: Bool + + public let mutedUntilTimestamp: TimeInterval? + public let onlyNotifyForMentions: Bool + public let wasMarkedUnread: Bool + public let unreadCount: Int + public let unreadMentionCount: Int + public let hasUnreadMessagesOfAnyKind: Bool + public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? + public let messageDraft: String + + public let canWrite: Bool + public let canUpload: Bool + public let canAccessSettings: Bool + public let shouldShowProBadge: Bool + public let isTyping: Bool + public let userCount: Int? + public let memberNames: String + public let targetInteraction: InteractionInfo? + public let lastInteraction: InteractionInfo? + public let userSessionId: SessionId + public let currentUserSessionIds: Set + + // Variant-specific configuration + + public let profile: Profile? + public let additionalProfile: Profile? + public let contactInfo: ContactInfo? + public let groupInfo: GroupInfo? + public let communityInfo: CommunityInfo? + + public var dateForDisplay: String { + let timestamp: TimeInterval + + switch (targetInteraction, lastInteraction) { + case (.some(let interaction), _): timestamp = (Double(interaction.timestampMs) / 1000) + case (_, .some(let interaction)): timestamp = (Double(interaction.timestampMs) / 1000) + default: timestamp = creationDateTimestamp + } + + return Date(timeIntervalSince1970: timestamp).formattedForDisplay + } + + public init( + thread: SessionThread, + dataCache: ConversationDataCache, + targetInteractionId: Int64? = nil, + searchText: String? = nil, + using dependencies: Dependencies + ) { + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: thread.id) + let isMessageRequest: Bool = ( + dataCache.group(for: thread.id)?.invited == true || ( + !currentUserSessionIds.contains(thread.id) && + dataCache.contact(for: thread.id)?.isApproved == false + ) + ) + let requiresApproval: Bool = (dataCache.contact(for: thread.id)?.didApproveMe == false) + let sortedMemberIds: [String] = dataCache.groupMembers(for: thread.id) + .map({ $0.profileId }) + .filter({ !currentUserSessionIds.contains($0) }) + .sorted() + let profile: Profile? = { + switch thread.variant { + case .contact: + /// If the thread is the Note to Self one then use the proper profile from the cache (instead of a random blinded one) + guard !currentUserSessionIds.contains(thread.id) else { + return (dataCache.profile(for: dataCache.userSessionId.hexString) ?? Profile.defaultFor(dataCache.userSessionId.hexString)) + } + + return (dataCache.profile(for: thread.id) ?? Profile.defaultFor(thread.id)) + + case .legacyGroup, .group: + let maybeTargetId: String? = sortedMemberIds.first + + return dataCache.profile(for: maybeTargetId ?? dataCache.userSessionId.hexString) + + case .community: return nil + } + }() + let lastInteraction: InteractionInfo? = dataCache.interactionStats(for: thread.id).map { + dataCache.interaction(for: $0.latestInteractionId).map { + InteractionInfo( + interaction: $0, + searchText: searchText, + threadVariant: thread.variant, + userSessionId: dataCache.userSessionId, + dataCache: dataCache, + using: dependencies + ) + } + } + let groupInfo: GroupInfo? = dataCache.group(for: thread.id).map { + GroupInfo( + group: $0, + dataCache: dataCache, + currentUserSessionIds: currentUserSessionIds + ) + } + let communityInfo: CommunityInfo? = dataCache.community(for: thread.id).map { + CommunityInfo( + openGroup: $0, + dataCache: dataCache + ) + } + + self.id = thread.id + self.variant = thread.variant + self.displayName = { + let result: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + groupName: dataCache.group(for: thread.id)?.name, + communityName: dataCache.community(for: thread.id)?.name, + isNoteToSelf: currentUserSessionIds.contains(thread.id), + ignoreNickname: false, + profile: profile + ) + + /// If this is being displayed as a conversation search result then we want to highlight the `searchTerm` in the `displayName` + /// + /// **Note:** If there is a `targetInteractionId` then this is a message search result and we don't want to highlight + /// the `searchText` within the title + guard let searchText: String = searchText, targetInteractionId == nil else { + return result + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: result + ) + }() + self.displayPictureUrl = { + switch thread.variant { + case .community: return dataCache.community(for: thread.id)?.displayPictureOriginalUrl + case .group, .legacyGroup: return dataCache.group(for: thread.id)?.displayPictureUrl + case .contact: return dataCache.profile(for: thread.id)?.displayPictureUrl + } + }() + self.conversationDescription = { + switch thread.variant { + case .contact, .legacyGroup: return nil + case .community: return dataCache.community(for: thread.id)?.roomDescription + case .group: return dataCache.group(for: thread.id)?.groupDescription + } + }() + self.creationDateTimestamp = thread.creationDateTimestamp + self.shouldBeVisible = thread.shouldBeVisible + self.pinnedPriority = (thread.pinnedPriority.map { Int32($0) } ?? LibSession.visiblePriority) + + self.isDraft = (thread.isDraft == true) + self.isNoteToSelf = currentUserSessionIds.contains(thread.id) + self.isBlocked = (dataCache.contact(for: thread.id)?.isBlocked == true) + self.isMessageRequest = isMessageRequest + self.requiresApproval = requiresApproval + + self.mutedUntilTimestamp = thread.mutedUntilTimestamp + self.onlyNotifyForMentions = thread.onlyNotifyForMentions + self.wasMarkedUnread = (thread.markedAsUnread == true) + self.disappearingMessagesConfiguration = dataCache.disappearingMessageConfiguration(for: thread.id) + self.messageDraft = (thread.messageDraft ?? "") + + self.canWrite = { + switch thread.variant { + case .contact: + guard isMessageRequest else { return true } + + /// If the thread is an incoming message request then we should be able to reply regardless of the original + /// senders `blocksCommunityMessageRequests` setting + guard requiresApproval else { return true } + + return (profile?.blocksCommunityMessageRequests != true) + + case .legacyGroup: return false + case .group: + guard + groupInfo?.isDestroyed != true, + groupInfo?.wasKicked != true + else { return false } + guard !isMessageRequest else { return true } + + return (lastInteraction?.variant.isGroupLeavingStatus != true) + + case .community: return (communityInfo?.permissions.contains(.write) ?? false) + } + }() + self.canUpload = { + switch thread.variant { + case .contact: + /// If the thread is an outgoing message request then we shouldn't be able to upload + return (requiresApproval == false) + + case .legacyGroup: return false + case .group: + guard + groupInfo?.isDestroyed != true, + groupInfo?.wasKicked != true + else { return false } + guard !isMessageRequest else { return true } + + return (lastInteraction?.variant.isGroupLeavingStatus != true) + + case .community: return (communityInfo?.permissions.contains(.upload) ?? false) + } + }() + self.canAccessSettings = ( + !requiresApproval && + !isMessageRequest && + variant != .legacyGroup + ) + + self.shouldShowProBadge = { + guard dependencies[feature: .sessionProEnabled] else { return false } + + switch thread.variant { + case .contact: + // TODO: [PRO] Need to check if the pro status on the profile has expired + return ( + dataCache.profile(for: thread.id)?.proFeatures.contains(.proBadge) == true || + dependencies[feature: .proBadgeEverywhere] + ) + + case .group: return false // TODO: [PRO] Determine if the group is PRO + case .community, .legacyGroup: return false + } + }() + self.isTyping = dataCache.isTyping(in: thread.id) + self.userCount = { + switch thread.variant { + case .contact: return nil + case .legacyGroup, .group: + return dataCache.groupMembers(for: thread.id) + .filter { $0.role != .zombie } + .count + + case .community: return Int(dataCache.community(for: thread.id)?.userCount ?? 0) + } + }() + self.memberNames = { + let memberNameString: String = dataCache.groupMembers(for: thread.id) + .compactMap { member in dataCache.profile(for: member.profileId) } + .map { profile in + profile.displayName( + showYouForCurrentUser: false /// Don't want to show `You` here as this is displayed in Global Search + ) + } + .joined(separator: ", ") + + /// If this is being displayed as a search result then we want to highlight the `searchTerm` in the `memberNameString` + /// + /// **Note:** If there is a `targetInteractionId` then this is a message search result and we won't be showing the + /// `memberNameString` so no need to highlight it + guard let searchText: String = searchText, targetInteractionId == nil else { + return memberNameString + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: memberNameString + ) + }() + + self.unreadCount = (dataCache.interactionStats(for: thread.id)?.unreadCount ?? 0) + self.unreadMentionCount = (dataCache.interactionStats(for: thread.id)?.unreadMentionCount ?? 0) + self.hasUnreadMessagesOfAnyKind = (dataCache.interactionStats(for: thread.id)?.hasUnreadMessagesOfAnyKind == true) + self.targetInteraction = targetInteractionId.map { id in + dataCache.interaction(for: id).map { + InteractionInfo( + interaction: $0, + searchText: searchText, + threadVariant: thread.variant, + userSessionId: dataCache.userSessionId, + dataCache: dataCache, + using: dependencies + ) + } + } + self.lastInteraction = lastInteraction + self.userSessionId = dataCache.userSessionId + self.currentUserSessionIds = currentUserSessionIds + + // Variant-specific configuration + + self.profile = profile.map { profile in + profile.with( + proFeatures: .set(to: { + guard dependencies[feature: .sessionProEnabled] else { return .none } + // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? + var result: SessionPro.ProfileFeatures = profile.proFeatures + + if dependencies[feature: .proBadgeEverywhere] { + result.insert(.proBadge) + } + + return result + }()) + ) + } + self.additionalProfile = { + switch thread.variant { + case .legacyGroup, .group: + guard + sortedMemberIds.count > 1, + let targetId: String = sortedMemberIds.last, + targetId != profile?.id + else { return nil } + + return dataCache.profile(for: targetId).map { profile in + profile.with( + proFeatures: .set(to: { + guard dependencies[feature: .sessionProEnabled] else { return .none } + // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? + var result: SessionPro.ProfileFeatures = profile.proFeatures + + if dependencies[feature: .proBadgeEverywhere] { + result.insert(.proBadge) + } + + return result + }()) + ) + } + + default: return nil + } + }() + self.contactInfo = dataCache.contact(for: thread.id).map { + ContactInfo( + contact: $0, + profile: profile, + threadVariant: thread.variant, + currentUserSessionIds: currentUserSessionIds + ) + } + self.groupInfo = groupInfo + self.communityInfo = communityInfo + } +} + +// MARK: - Observations + +extension ConversationInfoViewModel: ObservableKeyProvider { + public var observedKeys: Set { + var result: Set = [ + .conversationCreated, + .conversationUpdated(id), + .conversationDeleted(id), + .messageCreated(threadId: id), + .typingIndicator(id) + ] + + if SessionId.Prefix.isCommunityBlinded(id) { + result.insert(.anyContactUnblinded) + } + + if let targetInteraction: InteractionInfo = self.targetInteraction { + result.insert(.profile(targetInteraction.authorId)) + result.insert(.messageUpdated(id: targetInteraction.id, threadId: id)) + result.insert(.messageDeleted(id: targetInteraction.id, threadId: id)) + } + + if let lastInteraction: InteractionInfo = self.lastInteraction { + result.insert(.profile(lastInteraction.authorId)) + result.insert(.messageUpdated(id: lastInteraction.id, threadId: id)) + result.insert(.messageDeleted(id: lastInteraction.id, threadId: id)) + } + + if let profile: Profile = self.profile { + result.insert(.profile(profile.id)) + } + + if let additionalProfile: Profile = self.additionalProfile { + result.insert(.profile(additionalProfile.id)) + } + + if self.contactInfo != nil { + result.insert(.contact(id)) + } + + if self.groupInfo != nil { + result.insert(.groupInfo(groupId: id)) + } + + if self.communityInfo != nil { + result.insert(.communityUpdated(id)) + result.insert(.anyContactUnblinded) /// To update profile info and blinded mapping + } + + return result + } + + public static func handlingStrategy(for event: ObservedEvent) -> EventHandlingStrategy? { + return event.handlingStrategy + } +} + +public extension ConversationInfoViewModel { + // MARK: - Marking as Read + + enum ReadTarget { + /// Only the thread should be marked as read + case thread + + /// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the + /// thread will be marked as read + case threadAndInteractions(interactionsBeforeInclusive: Int64?) + } + + /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read + func markAsRead(target: ReadTarget, using dependencies: Dependencies) async throws { + let targetInteractionId: Int64? = { + guard case .threadAndInteractions(let interactionId) = target else { return nil } + guard hasUnreadMessagesOfAnyKind else { return nil } + + return (interactionId ?? self.lastInteraction?.id) + }() + + /// No need to do anything if the thread is already marked as read and we don't have a target interaction + guard wasMarkedUnread || targetInteractionId != nil else { return } + + /// Perform the updates + try await dependencies[singleton: .storage].writeAsync { [id, variant] db in + if wasMarkedUnread { + try SessionThread + .filter(id: id) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: false), + using: dependencies + ) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.markedAsUnread(false)) + ) + } + + if let interactionId: Int64 = targetInteractionId { + try Interaction.markAsRead( + db, + interactionId: interactionId, + threadId: id, + threadVariant: variant, + includingOlder: true, + trySendReadReceipt: SessionThread.canSendReadReceipt( + threadId: id, + threadVariant: variant, + using: dependencies + ), + using: dependencies + ) + } + } + } + + /// This method will mark a thread as read + func markAsUnread(using dependencies: Dependencies) async throws { + guard !wasMarkedUnread else { return } + + try await dependencies[singleton: .storage].writeAsync { [id] db in + try SessionThread + .filter(id: id) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: true), + using: dependencies + ) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.markedAsUnread(true)) + ) + } + } + + // MARK: - Draft + + func updateDraft(_ draft: String, using dependencies: Dependencies) async throws { + guard draft != self.messageDraft else { return } + + try await dependencies[singleton: .storage].writeAsync { [id, variant] db in + try SessionThread + .filter(id: id) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + db.addConversationEvent( + id: id, + variant: variant, + type: .updated(.draft(draft)) + ) + } + } +} + +// MARK: - Convenience Initialization + +public extension ConversationInfoViewModel { + private static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID" + + var isMessageRequestsSection: Bool { id == ConversationInfoViewModel.messageRequestsSectionId } + + static func unreadMessageRequestsBanner(unreadCount: Int) -> ConversationInfoViewModel { + return ConversationInfoViewModel( + id: messageRequestsSectionId, + displayName: "sessionMessageRequests".localized(), + unreadCount: unreadCount + ) + } + + private init( + id: String, + displayName: String = "", + unreadCount: Int = 0 + ) { + self.id = id + self.variant = .contact + self.displayName = displayName + self.displayPictureUrl = nil + self.conversationDescription = nil + self.creationDateTimestamp = 0 + self.shouldBeVisible = true + self.pinnedPriority = LibSession.visiblePriority + + self.isDraft = false + self.isNoteToSelf = false + self.isBlocked = false + self.isMessageRequest = false + self.requiresApproval = false + + self.mutedUntilTimestamp = nil + self.onlyNotifyForMentions = false + self.wasMarkedUnread = false + self.unreadCount = unreadCount + self.unreadMentionCount = 0 + self.hasUnreadMessagesOfAnyKind = false + self.disappearingMessagesConfiguration = nil + self.messageDraft = "" + + self.canWrite = false + self.canUpload = false + self.canAccessSettings = false + self.shouldShowProBadge = false + self.isTyping = false + self.userCount = nil + self.memberNames = "" + self.targetInteraction = nil + self.lastInteraction = nil + self.userSessionId = .invalid + self.currentUserSessionIds = [] + + // Variant-specific configuration + + self.profile = nil + self.additionalProfile = nil + self.contactInfo = nil + self.groupInfo = nil + self.communityInfo = nil + } +} + +// MARK: - ContactInfo + +public extension ConversationInfoViewModel { + struct ContactInfo: Sendable, Equatable, Hashable { + public let id: String + public let isCurrentUser: Bool + public let displayName: String + public let displayNameInMessageBody: String + public let isApproved: Bool + public let lastKnownClientVersion: FeatureVersion? + + init( + contact: Contact, + profile: Profile?, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set + ) { + self.id = contact.id + self.isCurrentUser = currentUserSessionIds.contains(contact.id) + self.displayName = (profile ?? Profile.defaultFor(contact.id)).displayName() + self.displayNameInMessageBody = (profile ?? Profile.defaultFor(contact.id)).displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + self.isApproved = contact.isApproved + self.lastKnownClientVersion = contact.lastKnownClientVersion + } + } +} + +// MARK: - GroupInfo + +public extension ConversationInfoViewModel { + struct GroupInfo: Sendable, Equatable, Hashable { + public let name: String + public let expired: Bool + public let wasKicked: Bool + public let isDestroyed: Bool + public let adminProfile: Profile? + public let currentUserRole: GroupMember.Role? + + init( + group: ClosedGroup, + dataCache: ConversationDataCache, + currentUserSessionIds: Set + ) { + let adminIds: [String] = dataCache.groupMembers(for: group.threadId) + .filter { $0.role == .admin } + .map { $0.profileId } + .sorted() + + self.name = group.name + self.expired = (group.expired == true) + self.wasKicked = (dataCache.groupInfo(for: group.threadId)?.wasKickedFromGroup == true) + self.isDestroyed = (dataCache.groupInfo(for: group.threadId)?.wasGroupDestroyed == true) + self.adminProfile = adminIds.compactMap { dataCache.profile(for: $0) }.first + self.currentUserRole = dataCache.groupMembers(for: group.threadId) + .filter { currentUserSessionIds.contains($0.profileId) } + .map { $0.role } + .sorted() + .last /// We want the highest-ranking role (in case there are multiple entries) + } + } +} + +// MARK: - CommunityInfo + +public extension ConversationInfoViewModel { + struct CommunityInfo: Sendable, Equatable, Hashable { + public let name: String + public let server: String + public let roomToken: String + public let publicKey: String + public let permissions: OpenGroup.Permissions + public let capabilities: Set + + init( + openGroup: OpenGroup, + dataCache: ConversationDataCache + ) { + self.name = openGroup.name + self.server = openGroup.server + self.roomToken = openGroup.roomToken + self.publicKey = openGroup.publicKey + self.permissions = (openGroup.permissions ?? .noPermissions) + self.capabilities = dataCache.communityCapabilities(for: openGroup.server) + } + } +} + +// MARK: - InteractionInfo + +public extension ConversationInfoViewModel { + struct InteractionInfo: Sendable, Equatable, Hashable { + public let id: Int64 + public let threadId: String + public let authorId: String + public let authorName: String + public let variant: Interaction.Variant + public let bubbleBody: String? + public let timestampMs: Int64 + public let state: Interaction.State + public let hasBeenReadByRecipient: Bool + public let hasAttachments: Bool + public let messageSnippet: String? + + public init?( + interaction: Interaction, + searchText: String?, + threadVariant: SessionThread.Variant, + userSessionId: SessionId, + dataCache: ConversationDataCache, + using dependencies: Dependencies + ) { + guard let interactionId: Int64 = interaction.id else { return nil } + + let contentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: interaction, + threadVariant: threadVariant, + searchText: searchText, + dataCache: dataCache + ) + + self.id = interactionId + self.threadId = interaction.threadId + self.authorId = interaction.authorId + self.authorName = contentBuilder.authorDisplayName + self.variant = interaction.variant + self.bubbleBody = contentBuilder.makeBubbleBody() + self.timestampMs = interaction.timestampMs + self.state = interaction.state + self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) + self.hasAttachments = !dataCache.interactionAttachments(for: interactionId).isEmpty + self.messageSnippet = contentBuilder.makeSnippet(dateNow: dependencies.dateNow) + } + } +} + +// MARK: - InteractionStats + +public extension ConversationInfoViewModel { + struct InteractionStats: Sendable, Codable, Equatable, Hashable, ColumnExpressible, FetchableRecord { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case unreadCount + case unreadMentionCount + case hasUnreadMessagesOfAnyKind + case latestInteractionId + case latestInteractionTimestampMs + } + + public let threadId: String + public let unreadCount: Int + public let unreadMentionCount: Int + public let hasUnreadMessagesOfAnyKind: Bool + public let latestInteractionId: Int64 + public let latestInteractionTimestampMs: Int64 + + public static func request(for conversationIds: Set) -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + \(interaction[.threadId]) AS \(Columns.threadId), + SUM(\(interaction[.wasRead]) = false) AS \(Columns.unreadCount), + SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(Columns.unreadMentionCount), + (SUM(\(interaction[.wasRead]) = false) > 0) AS \(Columns.hasUnreadMessagesOfAnyKind), + \(interaction[.id]) AS \(Columns.latestInteractionId), + MAX(\(interaction[.timestampMs])) AS \(Columns.latestInteractionTimestampMs) + FROM \(Interaction.self) + WHERE ( + \(interaction[.threadId]) IN \(conversationIds) AND + \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) + ) + GROUP BY \(interaction[.threadId]) + """ + } + } +} + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy? { + switch (key, key.generic) { + case (_, .profile): return [.databaseQuery, .directCacheUpdate] + case (_, .groupMemberCreated), (_, .groupMemberUpdated), (_, .groupMemberDeleted): + return [.databaseQuery, .directCacheUpdate] + + case (_, .groupInfo): return .libSessionQuery + + case (_, .typingIndicator): return .directCacheUpdate + case (_, .conversationUpdated): return .directCacheUpdate + case (_, .contact): return .directCacheUpdate + case (_, .communityUpdated): return .directCacheUpdate + + case (.anyContactBlockedStatusChanged, _): return .databaseQuery + case (_, .conversationCreated), (_, .conversationDeleted): return .databaseQuery + case (.anyMessageCreatedInAnyConversation, _): return .databaseQuery + case (_, .messageCreated), (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + default: return nil + } + } +} + +private extension ContactEvent.Change { + var isUnblindEvent: Bool { + switch self { + case .unblinded: return true + default: return false + } + } +} + +public extension SessionId.Prefix { + static func isCommunityBlinded(_ id: String?) -> Bool { + switch try? SessionId.Prefix(from: id) { + case .blinded15, .blinded25: return true + case .standard, .unblinded, .group, .versionBlinded07, .none: return false + } + } +} + +public extension ConversationInfoViewModel { + static var requiredJoinSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + + return """ + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral) + FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToShowConversationSnippet)")) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + """ + }() + + static func homeFilterSQL(userSessionId: SessionId) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + + return """ + \(thread[.shouldBeVisible]) = true AND + -- Is not a message request + COALESCE(\(closedGroup[.invited]), false) = false AND ( + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) OR + \(contact[.isApproved]) = true + ) AND + -- Is not a blocked contact + ( + \(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR + \(contact[.isBlocked]) != true + ) + """ + } + + static let homeOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL(""" + (IFNULL(\(thread[.pinnedPriority]), 0) > 0) DESC, + IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, + \(thread[.id]) DESC + """) + }() + + static func messageRequestsFilterSQL(userSessionId: SessionId) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + + return """ + \(thread[.shouldBeVisible]) = true AND ( + -- Is a message request + COALESCE(\(closedGroup[.invited]), false) = true OR ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) + ) + """ + } + + static let messageRequestsOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL(""" + IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC, + \(thread[.id]) DESC + """) + }() +} diff --git a/SessionMessagingKit/Types/GlobalSearch.swift b/SessionMessagingKit/Types/GlobalSearch.swift new file mode 100644 index 0000000000..6f8241b7d2 --- /dev/null +++ b/SessionMessagingKit/Types/GlobalSearch.swift @@ -0,0 +1,755 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +public enum GlobalSearch {} + +// MARK: - Helper Functions + +public extension GlobalSearch { + private class SearchTermParts { + let parts: [String] + + init(_ parts: [String]) { + self.parts = parts + } + } + + static let searchResultsLimit: Int = 500 + private static let rangeOptions: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + private static let alphanumericSet: NSCharacterSet = (CharacterSet.alphanumerics as NSCharacterSet) + private static let quoteCharacterSet: CharacterSet = CharacterSet(charactersIn: "\"") + private static let searchTermPartRegex: NSRegularExpression? = try? NSRegularExpression( + pattern: "[^\\s\"']+|\"([^\"]*)\"" // stringlint:ignore + ) + + /// Processing a search term requires a little logic and regex execution and we need the processed version for every search result in + /// order to properly highlight, as a result we cache the processed parts to avoid having to re-process. + private static let searchTermPartCache: NSCache = { + let result: NSCache = NSCache() + result.name = "GlobalSearchTermPartsCache" // stringlint:ignore + result.countLimit = 25 /// Last 25 search terms + + return result + }() + + /// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search + /// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL + /// is `MATCH '"{term}"'` or `MATCH '"{term}"*'` + static func searchSafeTerm(_ term: String) -> String { + return "\"\(term)\"" + } + + // stringlint:ignore_contents + static func searchTermParts(_ searchTerm: String) -> [String] { + /// Process the search term in order to extract the parts of the search pattern we want + /// + /// Step 1 - Keep any "quoted" sections as stand-alone search + /// Step 2 - Separate any words outside of quotes + /// Step 3 - Join the different search term parts with 'OR" (include results for each individual term) + /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) + let normalisedTerm: String = standardQuotes(searchTerm) + + guard let regex: NSRegularExpression = searchTermPartRegex else { + // Fallback to removing the quotes and just splitting on spaces + return normalisedTerm + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + .map { "\"\($0)\"" } + .filter { !$0.isEmpty } + } + + return regex + .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) + .compactMap { Range($0.range, in: normalisedTerm) } + .map { normalisedTerm[$0].trimmingCharacters(in: quoteCharacterSet) } + .map { "\"\($0)\"" } + } + + // stringlint:ignore_contents + static func standardQuotes(_ term: String) -> String { + guard term.contains("”") || term.contains("“") else { + return term + } + + /// Apple like to use the special '""' quote characters when typing so replace them with normal ones + return term + .replacingOccurrences(of: "”", with: "\"") + .replacingOccurrences(of: "“", with: "\"") + } + + static func pattern(_ db: ObservingDatabase, searchTerm: String) throws -> FTS5Pattern { + return try pattern(db, searchTerm: searchTerm, forTable: Interaction.self) + } + + // stringlint:ignore_contents + static func pattern(_ db: ObservingDatabase, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { + // Note: FTS doesn't support both prefix/suffix wild cards so don't bother trying to + // add a prefix one + let rawPattern: String = { + let result: String = searchTermParts(searchTerm) + .joined(separator: " OR ") + + // If the last character is a quotation mark then assume the user doesn't want to append + // a wildcard character + guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } + + return "\(result)*" + }() + let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" + + /// There are cases where creating a pattern can fail, we want to try and recover from those cases + /// by failling back to simpler patterns if needed + return try { + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { + return pattern + } + + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { + return pattern + } + + return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() + }() + } + + static func ranges( + for searchText: String, + in content: String + ) -> [NSRange] { + if content.isEmpty || searchText.isEmpty { return [] } + + let parts: [String] = { + let key: NSString = searchText as NSString + if let cacheHit: SearchTermParts = searchTermPartCache.object(forKey: key) { + return cacheHit.parts + } + + /// The search logic only finds results that start with the term so we use the regex below to ensure we only highlight those cases + let parts: [String] = GlobalSearch + .searchTermParts(searchText) + .map { part in + (part.hasPrefix("\"") && part.hasSuffix("\"") ? + part.trimmingCharacters(in: quoteCharacterSet) : + part + ) + } + + searchTermPartCache.setObject(SearchTermParts(parts), forKey: key) + return parts + }() + + let nsContent: NSString = content as NSString /// For O(1) indexing and direct `NSRange` usage + let contentLength: Int = nsContent.length + var allMatches: [NSRange] = [] + allMatches.reserveCapacity(4) // Estimate + + for part in parts { + var searchRange: NSRange = NSRange(location: 0, length: contentLength) + + while true { + let matchRange: NSRange = nsContent.range(of: part, options: rangeOptions, range: searchRange) + + guard matchRange.location != NSNotFound else { break } + + let isStartOfWord: Bool = { + if matchRange.location == 0 { return true } + + /// If the character before is a letter or number then we are inside a word (Invalid), otherwise (space, + /// punctuation, etc), we are at the start of a word (Valid) + let charBefore: unichar = nsContent.character(at: matchRange.location - 1) + + return !alphanumericSet.characterIsMember(charBefore) + }() + + /// If the match is at the start of the word then we can add it + if isStartOfWord { + allMatches.append(matchRange) + } + + /// We can now jump to the end of the match, if the same part has another match within the word (eg. "na" in + /// "banana") then the `isStartOfWord` check will prevent that from being added + let nextStart: Int = matchRange.location + matchRange.length + if nextStart >= contentLength { break } + searchRange = NSRange(location: nextStart, length: contentLength - nextStart) + } + } + + /// If we 0 or 1 match then we can just return now + guard allMatches.count <= 1 else { return allMatches } + + /// We want to match the longest parts if there were overlaps (eg. match "Testing" before "Test" if both are present) + /// + /// Sort by location ASC, then length DESC + if allMatches.count > 1 { + allMatches.sort { lhs, rhs in + if lhs.location != rhs.location { + return lhs.location < rhs.location + } + + return lhs.length > rhs.length + } + } + + /// Remove overlaps + var maxIndexProcessed: Int = 0 + var results: [NSRange] = [] + results.reserveCapacity(allMatches.count) + + for range in allMatches { + if range.location >= maxIndexProcessed { + results.append(range) + maxIndexProcessed = (range.location + range.length) + } + } + + return results + } + + static func highlightSearchText( + searchText: String, + content: String, + authorName: String? = nil + ) -> String { + guard !content.isEmpty, content != "noteToSelf".localized() else { + if let authorName: String = authorName, !authorName.isEmpty { + return "messageSnippetGroup" + .put(key: "author", value: authorName) + .put(key: "message_snippet", value: content) + .localized() + } + + return content + } + + /// Bold each part of the searh term which matched + var ranges: [NSRange] = GlobalSearch.ranges(for: searchText, in: content) + let mutableResult: NSMutableString = NSMutableString(string: content) + + // stringlint:ignore_contents + if !ranges.isEmpty { + /// Sort the ranges so they are in reverse order (that way we can insert bold tags without messing up the ranges + ranges.sort { $0.lowerBound > $1.lowerBound } + + for range in ranges { + mutableResult.insert("", at: range.upperBound) + mutableResult.insert("", at: range.lowerBound) + } + } + + /// Wrap entire result in `` tags (since we want everything else to be faded) + /// + /// **Note:** We do this even when `ranges` is empty because we want anything that doesn't contain a match to also + /// be faded + mutableResult.insert("", at: 0) // stringlint:ignore + mutableResult.append("") // stringlint:ignore + + /// If we don't have an `authorName` then we can finish here + guard let authorName: String = authorName, !authorName.isEmpty else { + return (mutableResult as String) + } + + /// Since it was provided we want to include the author name + return "messageSnippetGroup" + .put(key: "author", value: authorName) + .put(key: "message_snippet", value: (mutableResult as String)) + .localized() + } +} + +public extension ConversationDataHelper { + static func updateCacheForSearchResults( + _ db: ObservingDatabase, + currentCache: ConversationDataCache, + conversationResults: [GlobalSearch.ConversationSearchResult], + messageResults: [GlobalSearch.MessageSearchResult], + using dependencies: Dependencies + ) throws -> ConversationDataCache { + /// Find which ids need to be fetched (no need to re-fetch values we already have in the cache as they are very unlikely to + /// have changed during this search session) + let threadIds: Set = Set(conversationResults.map { $0.id }) + .subtracting(currentCache.threads.keys) + let messageThreadIds: Set = Set(messageResults.map { $0.threadId }) + .subtracting(currentCache.threads.keys) + let interactionIds: Set = Set(messageResults.map { $0.interactionId }) + .subtracting(currentCache.interactions.keys) + let allThreadIds: Set = threadIds.union(messageThreadIds) + + return try ConversationDataHelper.fetchFromDatabase( + db, + requirements: FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + threadIdsNeedingFetch: allThreadIds, + threadIdsNeedingInteractionStats: messageThreadIds, + interactionIdsNeedingFetch: interactionIds + ), + currentCache: currentCache, + using: dependencies + ) + } + + static func processSearchResults( + cache: ConversationDataCache, + searchText: String, + conversationResults: [GlobalSearch.ConversationSearchResult], + messageResults: [GlobalSearch.MessageSearchResult], + userSessionId: SessionId, + using dependencies: Dependencies + ) -> (conversations: [ConversationInfoViewModel], messages: [ConversationInfoViewModel]) { + let conversations: [ConversationInfoViewModel] = conversationResults.compactMap { result -> ConversationInfoViewModel? in + guard let thread: SessionThread = cache.thread(for: result.id) else { return nil } + + return ConversationInfoViewModel( + thread: thread, + dataCache: cache, + searchText: searchText, + using: dependencies + ) + } + let messages: [ConversationInfoViewModel] = messageResults.compactMap { result -> ConversationInfoViewModel? in + guard + let thread: SessionThread = cache.thread(for: result.threadId), + cache.interaction(for: result.interactionId) != nil + else { return nil } + + return ConversationInfoViewModel( + thread: thread, + dataCache: cache, + targetInteractionId: result.interactionId, + searchText: searchText, + using: dependencies + ) + } + + return (conversations, messages) + } + + static func generateCacheForDefaultContacts( + _ db: ObservingDatabase, + contactIds: [String], + using dependencies: Dependencies + ) throws -> ConversationDataCache { + return try ConversationDataHelper.fetchFromDatabase( + db, + requirements: FetchRequirements( + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false, + contactIdsNeedingFetch: Set(contactIds) + ), + currentCache: ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .searchResults, + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + using: dependencies + ) + } + + static func processDefaultContacts( + cache: ConversationDataCache, + contactIds: [String], + userSessionId: SessionId, + using dependencies: Dependencies + ) -> [ConversationInfoViewModel] { + return contactIds.compactMap { contactId -> ConversationInfoViewModel? in + guard cache.contact(for: contactId) != nil else { return nil } + + /// If there isn't a thread for the contact (because it's hidden) then we need to create one and insert it into a temporary + /// cache in order to build the view model + let thread: SessionThread = (cache.thread(for: contactId) ?? SessionThread( + id: contactId, + variant: .contact, + creationDateTimestamp: dependencies.dateNow.timeIntervalSince1970, + shouldBeVisible: false + )) + + var tempCache: ConversationDataCache = cache + tempCache.insert(thread) + + return ConversationInfoViewModel( + thread: thread, + dataCache: tempCache, + using: dependencies + ) + } + } +} + +// MARK: - ConversationSearchResult + +public extension GlobalSearch { + struct ConversationSearchResult: Decodable, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rank + case id + } + + public let rank: Double + public let id: String + + public static func defaultContactsQuery(userSessionId: SessionId) -> SQLRequest { + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + 100 AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + WHERE \(contact[.isBlocked]) = false + """ + } + + public static func noteToSelfOnlyQuery(userSessionId: SessionId) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + + return """ + SELECT + 100 AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + } + + /// This function does an FTS search against threads and their contacts to find any which contain the pattern + /// + /// **Note:** Unfortunately the FTS search only allows for a single pattern match per query which means we + /// need to combine the results of **all** of the following potential matches as unioned queries: + /// - Contact thread contact nickname + /// - Contact thread contact name + /// - Group name + /// - Group member nickname + /// - Group member name + /// - Community name + /// - "Note to self" text match + /// - Hidden contact nickname + /// - Hidden contact name + /// + /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the + /// returned results will always be `-1` for those results + public static func query( + userSessionId: SessionId, + pattern: FTS5Pattern, + searchTerm: String + ) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contactProfile: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let groupMemberProfile: TypedTableAlias = TypedTableAlias( + name: "groupMemberProfile" // stringlint:ignore + ) + let openGroup: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let profileFullTextSearch: TypedTableAlias = TypedTableAlias( + name: Profile.fullTextSearchTableName + ) + let closedGroupFullTextSearch: TypedTableAlias = TypedTableAlias( + name: ClosedGroup.fullTextSearchTableName + ) + let openGroupFullTextSearch: TypedTableAlias = TypedTableAlias( + name: OpenGroup.fullTextSearchTableName + ) + + let noteToSelfLiteral: SQL = SQL(stringLiteral: "noteToSelf".localized().lowercased()) + let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased()) + + var sqlQuery: SQL = "" + + // MARK: - Contact Thread Searches + + // Contact nickname search + sqlQuery += """ + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) + ) + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userSessionId.hexString)")) + ) + """ + + // MARK: - Group Searches + + // Group name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(closedGroupFullTextSearch) ON ( + \(closedGroupFullTextSearch[.rowId]) = \(closedGroup[.rowId]) AND + \(closedGroupFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // Group member nickname search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // Group member name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + JOIN \(GroupMember.self) ON ( + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND + \(groupMember[.groupId]) = \(thread[.id]) + ) + JOIN \(groupMemberProfile) ON \(groupMemberProfile[.id]) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(groupMemberProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) + """ + + // MARK: - Community Search + + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + JOIN \(openGroupFullTextSearch) ON ( + \(openGroupFullTextSearch[.rowId]) = \(openGroup[.rowId]) AND + \(openGroupFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) + """ + + // MARK: - Note to Self Searches + + // "Note to Self" literal match + sqlQuery += """ + + UNION ALL + + SELECT + 100 AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + WHERE ( + \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) AND + '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' + ) + """ + + // Note to self nickname search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + + // Note to self name search + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(thread[.id]) AS id + FROM \(SessionThread.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(thread[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE \(SQL("\(thread[.id]) = \(userSessionId.hexString)")) + """ + + // MARK: - Hidden Contact Searches + + // Hidden contact nickname + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.nickname]) MATCH \(pattern) + ) + WHERE NOT EXISTS ( + SELECT 1 FROM \(SessionThread.self) + WHERE \(thread[.id]) = \(contact[.id]) + ) + """ + + // Hidden contact name + sqlQuery += """ + + UNION ALL + + SELECT + IFNULL(\(Column.rank), 100) AS rank, + \(contact[.id]) AS id + FROM \(Contact.self) + JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch[.rowId]) = \(contactProfile[.rowId]) AND + \(profileFullTextSearch[.name]) MATCH \(pattern) + ) + WHERE NOT EXISTS ( + SELECT 1 FROM \(SessionThread.self) + WHERE \(thread[.id]) = \(contact[.id]) + ) + """ + + // Final grouping and ordering + let finalQuery: SQL = """ + WITH ranked_results AS ( + \(sqlQuery) + ) + SELECT r.rank, r.id + FROM ranked_results AS r + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = r.id + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = r.id + GROUP BY r.id + ORDER BY + r.rank, + CASE WHEN r.id = \(userSessionId.hexString) THEN 0 ELSE 1 END, + COALESCE(\(closedGroup[.name]), ''), + COALESCE(\(openGroup[.name]), ''), + r.id + LIMIT \(SQL("\(searchResultsLimit)")) + """ + + return SQLRequest(literal: finalQuery, cached: false) + } + } +} + +// MARK: - MessageSearchResult + +public extension GlobalSearch { + struct MessageSearchResult: Decodable, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case rank + case interactionId + case threadId + } + + public let rank: Double + public let interactionId: Int64 + public let threadId: String + + public static func query( + userSessionId: SessionId, + pattern: FTS5Pattern + ) -> SQLRequest { + let interaction: TypedTableAlias = TypedTableAlias() + let interactionFullTextSearch: TypedTableAlias = TypedTableAlias( + name: Interaction.fullTextSearchTableName + ) + + return """ + SELECT + \(Column.rank) AS rank, + \(interaction[.id]) AS interactionId, + \(interaction[.threadId]) AS threadId + FROM \(Interaction.self) + JOIN \(interactionFullTextSearch) ON ( + \(interactionFullTextSearch[.rowId]) = \(interaction[.rowId]) AND + \(interactionFullTextSearch[.body]) MATCH \(pattern) + ) + ORDER BY \(Column.rank), \(interaction[.timestampMs].desc) + LIMIT \(SQL("\(searchResultsLimit)")) + """ + } + } +} diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift similarity index 59% rename from SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift rename to SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift index 4fcda12730..ca44420ade 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Types/MessageViewModel+DeletionActions.swift @@ -118,10 +118,11 @@ public extension MessageViewModel { public extension MessageViewModel.DeletionBehaviours { static func deletionActions( for cellViewModels: [MessageViewModel], - threadData: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, isUserModeratorOrAdmin: Bool, using dependencies: Dependencies - ) -> MessageViewModel.DeletionBehaviours? { + ) throws -> MessageViewModel.DeletionBehaviours? { enum SelectedMessageState { case outgoingOnly case containsIncoming @@ -129,7 +130,7 @@ public extension MessageViewModel.DeletionBehaviours { } /// If it's a legacy group and they have been deprecated then the user shouldn't be able to delete messages - guard threadData.threadVariant != .legacyGroup else { return nil } + guard threadInfo.variant != .legacyGroup else { return nil } /// First determine the state of the selected messages let state: SelectedMessageState = { @@ -146,219 +147,204 @@ public extension MessageViewModel.DeletionBehaviours { }() /// The remaining deletion options are more complicated to determine - // FIXME: [Database Relocation] Remove this database usage - var deletionBehaviours: MessageViewModel.DeletionBehaviours? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + let isAdmin: Bool = { + switch threadInfo.variant { + case .contact: return false + case .group, .legacyGroup: return (threadInfo.groupInfo?.currentUserRole == .admin) + case .community: return isUserModeratorOrAdmin + } + }() - dependencies[singleton: .storage].readAsync( - retrieve: { [dependencies] db -> MessageViewModel.DeletionBehaviours? in - let isAdmin: Bool = { - switch threadData.threadVariant { - case .contact: return false - case .group, .legacyGroup: return (threadData.currentUserIsClosedGroupAdmin == true) - case .community: return isUserModeratorOrAdmin - } - }() - - switch (state, isAdmin) { - /// User selects messages including a control, pending or “deleted” message - case (.containsLocalOnlyMessages, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: (threadData.threadIsNoteToSelf ? - "deleteMessageNoteToSelfWarning" - .putNumber(cellViewModels.count) - .localized() : - "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized() - ), - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - - /// Control messages and deleted messages should be immediately deleted from the database - .deleteFromDatabase( - cellViewModels - .filter { viewModel in - viewModel.variant.isInfoMessage || - viewModel.variant.isDeletedMessage - } - .map { $0.id } - ), - - /// Other message types should only be marked as deleted - .markAsDeleted( - ids: cellViewModels - .filter { viewModel in - !viewModel.variant.isInfoMessage && - !viewModel.variant.isDeletedMessage - } - .map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] + switch (state, isAdmin) { + /// User selects messages including a control, pending or “deleted” message + case (.containsLocalOnlyMessages, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: (threadInfo.isNoteToSelf ? + "deleteMessageNoteToSelfWarning" + .putNumber(cellViewModels.count) + .localized() : + "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized() + ), + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + + /// Control messages and deleted messages should be immediately deleted from the database + .deleteFromDatabase( + cellViewModels + .filter { viewModel in + viewModel.variant.isInfoMessage || + viewModel.variant.isDeletedMessage + } + .map { $0.id } ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() - ), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") + + /// Other message types should only be marked as deleted + .markAsDeleted( + ids: cellViewModels + .filter { viewModel in + !viewModel.variant.isInfoMessage && + !viewModel.variant.isDeletedMessage + } + .map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: (threadInfo.isNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") ) - - /// User selects messages including only their own messages - case (.outgoingOnly, _): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: (threadData.threadIsNoteToSelf ? - "deleteMessageDevicesAll".localized() : - "deleteMessageEveryone".localized() - ), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies - ) + ] + ) + + /// User selects messages including only their own messages + case (.outgoingOnly, _): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: (threadInfo.isNoteToSelf ? + "deleteMessageDevicesAll".localized() : + "deleteMessageEveryone".localized() + ), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + isAdmin: isAdmin, + threadInfo: threadInfo, + authMethod: authMethod, + cellViewModels: cellViewModels, + using: dependencies + ) ) - - /// User selects messages including ones from other users - case (.containsIncoming, false): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: "deleteMessageWarning" - .putNumber(cellViewModels.count) - .localized(), - body: "deleteMessageDescriptionDevice" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .disabled, - accessibility: Accessibility(identifier: "Delete for everyone") + ] + ) + + /// User selects messages including ones from other users + case (.containsIncoming, false): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: "deleteMessageWarning" + .putNumber(cellViewModels.count) + .localized(), + body: "deleteMessageDescriptionDevice" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .disabled, + accessibility: Accessibility(identifier: "Delete for everyone") ) - - /// Admin can multi-select their own messages and messages from other users - case (.containsIncoming, true): - return MessageViewModel.DeletionBehaviours( - title: "deleteMessage" - .putNumber(cellViewModels.count) - .localized(), - warning: nil, - body: "deleteMessageConfirm" - .putNumber(cellViewModels.count) - .localized(), - actions: [ - NamedAction( - title: "deleteMessageDeviceOnly".localized(), - state: .enabled, - accessibility: Accessibility(identifier: "Delete for me"), - behaviours: [ - .cancelPendingSendJobs(cellViewModels.map { $0.id }), - .markAsDeleted( - ids: cellViewModels.map { $0.id }, - options: .local, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant - ) - ] - ), - NamedAction( - title: "deleteMessageEveryone".localized(), - state: .enabledAndDefaultSelected, - accessibility: Accessibility(identifier: "Delete for everyone"), - behaviours: try deleteForEveryoneBehaviours( - db, - isAdmin: isAdmin, - threadData: threadData, - cellViewModels: cellViewModels, - using: dependencies - ) + ] + ) + + /// Admin can multi-select their own messages and messages from other users + case (.containsIncoming, true): + return MessageViewModel.DeletionBehaviours( + title: "deleteMessage" + .putNumber(cellViewModels.count) + .localized(), + warning: nil, + body: "deleteMessageConfirm" + .putNumber(cellViewModels.count) + .localized(), + actions: [ + NamedAction( + title: "deleteMessageDeviceOnly".localized(), + state: .enabled, + accessibility: Accessibility(identifier: "Delete for me"), + behaviours: [ + .cancelPendingSendJobs(cellViewModels.map { $0.id }), + .markAsDeleted( + ids: cellViewModels.map { $0.id }, + options: .local, + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ] + ), + NamedAction( + title: "deleteMessageEveryone".localized(), + state: .enabledAndDefaultSelected, + accessibility: Accessibility(identifier: "Delete for everyone"), + behaviours: try deleteForEveryoneBehaviours( + isAdmin: isAdmin, + threadInfo: threadInfo, + authMethod: authMethod, + cellViewModels: cellViewModels, + using: dependencies + ) ) - } - }, - completion: { result in - deletionBehaviours = try? result.successOrThrow() - semaphore.signal() - } - ) - semaphore.wait() - - return deletionBehaviours + ] + ) + } } private static func deleteForEveryoneBehaviours( - _ db: ObservingDatabase, isAdmin: Bool, - threadData: SessionThreadViewModel, + threadInfo: ConversationInfoViewModel, + authMethod: AuthenticationMethod, cellViewModels: [MessageViewModel], using dependencies: Dependencies ) throws -> [Behaviour] { /// The non-local deletion behaviours differ depending on the type of conversation - switch (threadData.threadVariant, isAdmin) { + switch (threadInfo.variant, isAdmin) { /// **Note to Self or Contact Conversation** /// Delete from all participant devices via an `UnsendRequest` (these will trigger their own sync messages) /// Delete from the current users swarm (where possible) @@ -366,7 +352,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.contact, _): /// Only include messages sent by the current user (can't delete incoming messages in contact conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { threadData.currentUserSessionId.contains($0.authorId) } + .filter { threadInfo.currentUserSessionIds.contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in message.reactionInfo.compactMap { $0.reaction.serverHash } @@ -375,7 +361,7 @@ public extension MessageViewModel.DeletionBehaviours { try MessageSender.preparedSend( message: UnsendRequest( timestamp: UInt64(model.timestampMs), - author: threadData.currentUserSessionId + author: threadInfo.userSessionId.hexString ) .with( expiresInSeconds: model.expiresInSeconds, @@ -385,12 +371,7 @@ public extension MessageViewModel.DeletionBehaviours { namespace: .default, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -408,7 +389,7 @@ public extension MessageViewModel.DeletionBehaviours { try Network.SnodeAPI.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, - swarmPublicKey: threadData.threadId, + swarmPublicKey: threadInfo.id, using: dependencies ).map { _, _ in () } ) @@ -416,12 +397,12 @@ public extension MessageViewModel.DeletionBehaviours { ) .appending(serverHashes.isEmpty ? nil : .preparedRequest( + /// Need to delete the the current users swarm which needs it's own `authMethod` try Network.SnodeAPI.preparedDeleteMessages( serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, authMethod: try Authentication.with( - db, - swarmPublicKey: threadData.currentUserSessionId, + swarmPublicKey: threadInfo.userSessionId.hexString, using: dependencies ), using: dependencies @@ -429,14 +410,14 @@ public extension MessageViewModel.DeletionBehaviours { .map { _, _ in () } ) ) - .appending(threadData.threadIsNoteToSelf ? + .appending(threadInfo.isNoteToSelf ? /// If it's the `Note to Self`conversation then we want to just delete the interaction .deleteFromDatabase(cellViewModels.map { $0.id }) : .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -448,13 +429,13 @@ public extension MessageViewModel.DeletionBehaviours { case (.legacyGroup, _): /// Only try to delete messages send by other users if the current user is an admin let targetViewModels: [MessageViewModel] = cellViewModels - .filter { isAdmin || (threadData.currentUserSessionIds ?? []).contains($0.authorId) } + .filter { isAdmin || threadInfo.currentUserSessionIds.contains($0.authorId) } let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( message: UnsendRequest( timestamp: UInt64(model.timestampMs), author: (model.variant == .standardOutgoing ? - threadData.currentUserSessionId : + threadInfo.userSessionId.hexString : model.authorId ) ) @@ -466,12 +447,7 @@ public extension MessageViewModel.DeletionBehaviours { namespace: .legacyClosedGroup, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -489,7 +465,7 @@ public extension MessageViewModel.DeletionBehaviours { try Network.SnodeAPI.preparedBatch( requests: unsendRequestChunk, requireAllBatchResponses: false, - swarmPublicKey: threadData.threadId, + swarmPublicKey: threadInfo.id, using: dependencies ).map { _, _ in () } ) @@ -499,8 +475,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -512,7 +488,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, false): /// Only include messages sent by the current user (non-admins can't delete incoming messages in group conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } + .filter { threadInfo.currentUserSessionIds.contains($0.authorId) } let serverHashes: Set = targetViewModels.compactMap { $0.serverHash }.asSet() .inserting(contentsOf: Set(targetViewModels.flatMap { message in message.reactionInfo.compactMap { $0.reaction.serverHash } @@ -530,16 +506,11 @@ public extension MessageViewModel.DeletionBehaviours { authMethod: nil, using: dependencies ), - to: .group(publicKey: threadData.threadId), + to: .group(publicKey: threadInfo.id), namespace: .groupMessages, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -550,8 +521,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: targetViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -562,7 +533,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, true): guard let ed25519SecretKey: [UInt8] = dependencies.mutate(cache: .libSession, { cache in - cache.secretKey(groupSessionId: SessionId(.group, hex: threadData.threadId)) + cache.secretKey(groupSessionId: SessionId(.group, hex: threadInfo.id)) }) else { Log.error("[ConversationViewModel] Failed to retrieve groupIdentityPrivateKey when trying to delete messages from group.") @@ -584,21 +555,16 @@ public extension MessageViewModel.DeletionBehaviours { messageHashes: Array(serverHashes), sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs(), authMethod: Authentication.groupAdmin( - groupSessionId: SessionId(.group, hex: threadData.threadId), + groupSessionId: SessionId(.group, hex: threadInfo.id), ed25519SecretKey: ed25519SecretKey ), using: dependencies ), - to: .group(publicKey: threadData.threadId), + to: .group(publicKey: threadInfo.id), namespace: .groupMessages, interactionId: nil, attachments: nil, - authMethod: try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ), + authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) @@ -611,7 +577,7 @@ public extension MessageViewModel.DeletionBehaviours { serverHashes: Array(serverHashes), requireSuccessfulDeletion: false, authMethod: Authentication.groupAdmin( - groupSessionId: SessionId(.group, hex: threadData.threadId), + groupSessionId: SessionId(.group, hex: threadInfo.id), ed25519SecretKey: Array(ed25519SecretKey) ), using: dependencies @@ -622,8 +588,8 @@ public extension MessageViewModel.DeletionBehaviours { .markAsDeleted( ids: cellViewModels.map { $0.id }, options: [.local, .network], - threadId: threadData.threadId, - threadVariant: threadData.threadVariant + threadId: threadInfo.id, + threadVariant: threadInfo.variant ) ) @@ -634,17 +600,11 @@ public extension MessageViewModel.DeletionBehaviours { /// **Note:** To simplify the logic (since the sender is a blinded id) we don't bother doing admin/sender checks here /// and just rely on the UI state or the SOGS server (if the UI allows an invalid case) to prevent invalid behaviours case (.community, _): - guard let roomToken: String = threadData.openGroupRoomToken else { + guard let roomToken: String = threadInfo.communityInfo?.roomToken else { Log.error("[ConversationViewModel] Failed to retrieve community info when trying to delete messages.") throw StorageError.objectNotFound } - let authMethod: AuthenticationMethod = try Authentication.with( - db, - threadId: threadData.threadId, - threadVariant: threadData.threadVariant, - using: dependencies - ) let deleteRequests: [Network.PreparedRequest] = try cellViewModels .compactMap { $0.openGroupServerMessageId } .map { messageId in diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift similarity index 59% rename from SessionMessagingKit/Shared Models/MessageViewModel.swift rename to SessionMessagingKit/Types/MessageViewModel.swift index a9da9d2800..042c82bd87 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -74,8 +74,9 @@ public struct MessageViewModel: Sendable, Equatable, Hashable, Identifiable, Dif /// The value will be populated if the sender has a blinded id and we have resolved it to an unblinded id public let authorUnblindedId: String? - public let body: String? + public let bubbleBody: String? public let rawBody: String? + public let bodyForCopying: String? public let timestampMs: Int64 public let receivedAtTimestampMs: Int64 public let expiresStartedAtMs: Double? @@ -164,7 +165,9 @@ public extension MessageViewModel { self.cellType = cellType self.timestampMs = timestampMs self.variant = variant - self.body = body + self.bubbleBody = body + self.rawBody = body + self.bodyForCopying = body self.quoteViewModel = quoteViewModel /// These values shouldn't be used for the custom types @@ -176,7 +179,6 @@ public extension MessageViewModel { self.openGroupServerMessageId = nil self.authorId = "" self.authorUnblindedId = nil - self.rawBody = nil self.receivedAtTimestampMs = 0 self.expiresStartedAtMs = nil self.expiresInSeconds = nil @@ -212,21 +214,12 @@ public extension MessageViewModel { init?( optimisticMessageId: Int64? = nil, - threadId: String, - threadVariant: SessionThread.Variant, - threadIsTrusted: Bool, - threadDisappearingConfiguration: DisappearingMessagesConfiguration?, interaction: Interaction, - reactionInfo: [ReactionInfo]?, + reactionInfo: [MessageViewModel.ReactionInfo]?, maybeUnresolvedQuotedInfo: MaybeUnresolvedQuotedInfo?, - profileCache: [String: Profile], - attachmentCache: [String: Attachment], - linkPreviewCache: [String: [LinkPreview]], - attachmentMap: [Int64: Set], - unblindedIdMap: [String: String], - isSenderModeratorOrAdmin: Bool, userSessionId: SessionId, - currentUserSessionIds: Set, + threadInfo: ConversationInfoViewModel, + dataCache: ConversationDataCache, previousInteraction: Interaction?, nextInteraction: Interaction?, isLast: Bool, @@ -242,91 +235,81 @@ public extension MessageViewModel { case (.none, .none): return nil } + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: threadInfo.id) let targetProfile: Profile = { /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) guard !currentUserSessionIds.contains(interaction.authorId) else { - return (profileCache[userSessionId.hexString] ?? Profile.defaultFor(userSessionId.hexString)) + return (dataCache.profile(for: userSessionId.hexString) ?? Profile.defaultFor(userSessionId.hexString)) } - switch (profileCache[unblindedIdMap[interaction.authorId]], profileCache[interaction.authorId]) { - case (.some(let profile), _): return profile - case (_, .some(let profile)): return profile - case (.none, .none): return Profile.defaultFor(interaction.authorId) - } - }() - let threadContactDisplayName: String? = { - switch threadVariant { - case .contact: - return Profile.displayName( - id: threadId, - name: profileCache[threadId]?.name, - nickname: profileCache[threadId]?.nickname - ) - - default: return nil + if let unblindedProfile: Profile = dataCache.unblindedId(for: interaction.authorId).map({ dataCache.profile(for: $0) }) { + return unblindedProfile } + + return (dataCache.profile(for: interaction.authorId) ?? Profile.defaultFor(interaction.authorId)) }() - let linkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = interaction.linkPreview( - linkPreviewCache: linkPreviewCache, - attachmentCache: attachmentCache - ) - let attachments: [Attachment] = (attachmentMap[targetId]? - .sorted { $0.albumIndex < $1.albumIndex } - .compactMap { attachmentCache[$0.attachmentId] } ?? []) - let body: String? = interaction.body( - threadId: threadId, - threadVariant: threadVariant, - threadContactDisplayName: threadContactDisplayName, - authorDisplayName: (currentUserSessionIds.contains(targetProfile.id) ? - "you".localized() : - targetProfile.displayName( - includeSessionIdSuffix: (threadVariant == .community) - ) - ), - attachments: attachments, - linkPreview: linkPreviewInfo?.preview, - using: dependencies + let contentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: interaction, + threadVariant: threadInfo.variant, + dataCache: dataCache ) let proMessageFeatures: SessionPro.MessageFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } + if dependencies[feature: .forceMessageFeatureLongMessage] { + return interaction.proMessageFeatures.union(.largerCharacterLimit) + } + return interaction.proMessageFeatures - .union(dependencies[feature: .forceMessageFeatureLongMessage] ? .largerCharacterLimit : .none) }() let proProfileFeatures: SessionPro.ProfileFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } + // TODO: [PRO] Need to check if the pro status on the profile has expired + var result: SessionPro.ProfileFeatures = interaction.proProfileFeatures - return interaction.proProfileFeatures - .union(dependencies[feature: .forceMessageFeatureProBadge] ? .proBadge : .none) - .union(dependencies[feature: .forceMessageFeatureAnimatedAvatar] ? .animatedAvatar : .none) + if dependencies[feature: .forceMessageFeatureProBadge] { + result.insert(.proBadge) + } + + if dependencies[feature: .forceMessageFeatureAnimatedAvatar] { + result.insert(.animatedAvatar) + } + + return result }() self.cellType = MessageViewModel.cellType( interaction: interaction, - attachments: attachments + attachments: contentBuilder.attachments ) self.optimisticMessageId = optimisticMessageId - self.threadId = threadId - self.threadVariant = threadVariant - self.threadIsTrusted = threadIsTrusted + self.threadId = threadInfo.id + self.threadVariant = threadInfo.variant + self.threadIsTrusted = { + switch threadInfo.variant { + case .legacyGroup, .community, .group: return true /// Default to `true` for non-contact threads + case .contact: return (dataCache.contact(for: threadInfo.id)?.isTrusted == true) + } + }() self.id = targetId self.variant = interaction.variant self.serverHash = interaction.serverHash self.openGroupServerMessageId = interaction.openGroupServerMessageId self.authorId = interaction.authorId - self.authorUnblindedId = unblindedIdMap[authorId] - self.body = body + self.authorUnblindedId = dataCache.unblindedId(for: authorId) + self.bubbleBody = contentBuilder.makeBubbleBody() self.rawBody = interaction.body + self.bodyForCopying = contentBuilder.makeBodyForCopying() self.timestampMs = interaction.timestampMs self.receivedAtTimestampMs = interaction.receivedAtTimestampMs self.expiresStartedAtMs = interaction.expiresStartedAtMs self.expiresInSeconds = interaction.expiresInSeconds - self.attachments = attachments + self.attachments = contentBuilder.attachments self.reactionInfo = (reactionInfo ?? []) self.profile = targetProfile.with( proFeatures: .set(to: { guard dependencies[feature: .sessionProEnabled] else { return .none } - + // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? var result: SessionPro.ProfileFeatures = targetProfile.proFeatures if dependencies[feature: .proBadgeEverywhere] { @@ -363,26 +346,27 @@ public extension MessageViewModel { let quotedAuthorProfile: Profile = { /// If the sender is the current user then use the proper profile from the cache (instead of a random blinded one) guard !currentUserSessionIds.contains(quotedInteraction.authorId) else { - return (profileCache[userSessionId.hexString] ?? Profile.defaultFor(userSessionId.hexString)) + return (dataCache.profile(for: userSessionId.hexString) ?? Profile.defaultFor(userSessionId.hexString)) } - switch (profileCache[unblindedIdMap[quotedInteraction.authorId]], profileCache[quotedInteraction.authorId]) { - case (.some(let profile), _): return profile - case (_, .some(let profile)): return profile - case (.none, .none): return Profile.defaultFor(quotedInteraction.authorId) + if let unblindedProfile: Profile = dataCache.unblindedId(for: quotedInteraction.authorId).map({ dataCache.profile(for: $0) }) { + return unblindedProfile } + + return ( + dataCache.profile(for: quotedInteraction.authorId) ?? + Profile.defaultFor(quotedInteraction.authorId) + ) }() - let quotedAuthorDisplayName: String = quotedAuthorProfile.displayName( - includeSessionIdSuffix: (threadVariant == .community) + let quotedContentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: quotedInteraction, + threadVariant: threadInfo.variant, + dataCache: dataCache ) - let quotedAttachments: [Attachment]? = (attachmentMap[quotedInteractionId]? - .sorted { $0.albumIndex < $1.albumIndex } - .compactMap { attachmentCache[$0.attachmentId] } ?? []) - let quotedLinkPreviewInfo: (preview: LinkPreview, attachment: Attachment?)? = quotedInteraction.linkPreview( - linkPreviewCache: linkPreviewCache, - attachmentCache: attachmentCache + let targetQuotedAttachment: Attachment? = ( + quotedContentBuilder.attachments.first ?? + quotedContentBuilder.linkPreviewAttachment ) - let targetQuotedAttachment: Attachment? = (quotedAttachments?.first ?? quotedLinkPreviewInfo?.attachment) return QuoteViewModel( mode: .regular, @@ -390,17 +374,9 @@ public extension MessageViewModel { quotedInfo: QuoteViewModel.QuotedInfo( interactionId: quotedInteractionId, authorId: quotedInteraction.authorId, - authorName: quotedAuthorDisplayName, + authorName: quotedContentBuilder.authorDisplayName, timestampMs: quotedInteraction.timestampMs, - body: quotedInteraction.body( - threadId: threadId, - threadVariant: threadVariant, - threadContactDisplayName: threadContactDisplayName, - authorDisplayName: quotedAuthorDisplayName, - attachments: quotedAttachments, - linkPreview: quotedLinkPreviewInfo?.preview, - using: dependencies - ), + body: quotedContentBuilder.makeBubbleBody(), attachmentInfo: targetQuotedAttachment.map { quotedAttachment in let utType: UTType = (UTType(sessionMimeType: quotedAttachment.contentType) ?? .invalid) @@ -429,42 +405,41 @@ public extension MessageViewModel { ), showProBadge: { guard dependencies[feature: .sessionProEnabled] else { return false } - + // TODO: [PRO] Need to check if the pro status on the profile has expired return ( quotedAuthorProfile.proFeatures.contains(.proBadge) || dependencies[feature: .proBadgeEverywhere] ) }(), currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: { sessionId, _ in - guard !currentUserSessionIds.contains(targetProfile.id) else { return "you".localized() } - - return profileCache[sessionId]?.displayName( - includeSessionIdSuffix: (threadVariant == .community) - ) - }, + displayNameRetriever: dataCache.displayNameRetriever( + for: threadInfo.id, + includeSessionIdSuffixWhenInMessageBody: (threadInfo.variant == .community) + ), currentUserMentionImage: currentUserMentionImage ) } - self.linkPreview = linkPreviewInfo?.preview - self.linkPreviewAttachment = linkPreviewInfo?.attachment + self.linkPreview = contentBuilder.linkPreview + self.linkPreviewAttachment = contentBuilder.linkPreviewAttachment self.proMessageFeatures = proMessageFeatures self.proProfileFeatures = proProfileFeatures self.state = interaction.state self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) self.mostRecentFailureText = interaction.mostRecentFailureText - self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin + self.isSenderModeratorOrAdmin = dataCache + .communityModAdminIds(for: threadInfo.id) + .contains(interaction.authorId) self.canFollowDisappearingMessagesSetting = { guard - threadVariant == .contact && + threadInfo.variant == .contact && interaction.variant == .infoDisappearingMessagesUpdate && !currentUserSessionIds.contains(interaction.authorId) else { return false } return ( - threadDisappearingConfiguration != DisappearingMessagesConfiguration - .defaultWith(threadId) + dataCache.disappearingMessageConfiguration(for: threadInfo.id) != DisappearingMessagesConfiguration + .defaultWith(threadInfo.id) .with( isEnabled: (interaction.expiresInSeconds ?? 0) > 0, durationSeconds: interaction.expiresInSeconds, @@ -536,8 +511,8 @@ public extension MessageViewModel { ) ) self.shouldShowDateHeader = shouldShowDateBeforeThisModel - self.containsOnlyEmoji = (body?.containsOnlyEmoji == true) - self.glyphCount = (body?.glyphCount ?? 0) + self.containsOnlyEmoji = contentBuilder.containsOnlyEmoji + self.glyphCount = contentBuilder.glyphCount self.previousVariant = previousInteraction?.variant let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = { @@ -599,8 +574,9 @@ public extension MessageViewModel { openGroupServerMessageId: openGroupServerMessageId, authorId: authorId, authorUnblindedId: authorUnblindedId, - body: body, + bubbleBody: bubbleBody, rawBody: rawBody, + bodyForCopying: bodyForCopying, timestampMs: timestampMs, receivedAtTimestampMs: receivedAtTimestampMs, expiresStartedAtMs: expiresStartedAtMs, @@ -645,6 +621,49 @@ public extension MessageViewModel { } } +// MARK: - Observations + +extension MessageViewModel: ObservableKeyProvider { + public var observedKeys: Set { + var result: Set = [ + .messageUpdated(id: id, threadId: threadId), + .messageDeleted(id: id, threadId: threadId), + .reactionsChanged(messageId: id), + .attachmentCreated(messageId: id), + .profile(authorId) + ] + + if SessionId.Prefix.isCommunityBlinded(threadId) { + result.insert(.anyContactUnblinded) /// Author/Profile info could change + } + + attachments.forEach { attachment in + result.insert(.attachmentUpdated(id: attachment.id, messageId: id)) + result.insert(.attachmentDeleted(id: attachment.id, messageId: id)) + } + + if + let quoteViewModel: QuoteViewModel = quoteViewModel, + let quotedInfo: QuoteViewModel.QuotedInfo = quoteViewModel.quotedInfo + { + result.insert(.profile(quotedInfo.authorId)) + result.insert(.messageUpdated(id: quotedInfo.interactionId, threadId: threadId)) + result.insert(.messageDeleted(id: quotedInfo.interactionId, threadId: threadId)) + + if let attachmentInfo: QuoteViewModel.AttachmentInfo = quotedInfo.attachmentInfo { + result.insert(.attachmentUpdated(id: attachmentInfo.id, messageId: quotedInfo.interactionId)) + result.insert(.attachmentDeleted(id: attachmentInfo.id, messageId: quotedInfo.interactionId)) + } + } + + return result + } + + public static func handlingStrategy(for event: ObservedEvent) -> EventHandlingStrategy? { + return event.handlingStrategy + } +} + // MARK: - DisappeaingMessagesUpdateControlMessage public extension MessageViewModel { @@ -686,7 +705,7 @@ public extension MessageViewModel { } // MARK: - TypingIndicatorInfo - +// TODO: [PRO] Is this needed???? public extension MessageViewModel { struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { public typealias Columns = CodingKeys @@ -725,9 +744,23 @@ public extension MessageViewModel { // MARK: - Convenience +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy? { + switch (key, key.generic) { + case (.anyContactUnblinded, _): return [.databaseQuery, .directCacheUpdate] + case (_, .messageUpdated), (_, .messageDeleted): return .databaseQuery + case (_, .attachmentUpdated), (_, .attachmentDeleted): return .databaseQuery + case (_, .reactionsChanged): return .databaseQuery + case (_, .communityUpdated): return [.directCacheUpdate] + case (_, .contact): return [.directCacheUpdate] + case (_, .profile): return [.directCacheUpdate] + case (_, .typingIndicator): return .directCacheUpdate + default: return nil + } + } +} + extension MessageViewModel { - private static let maxMinutesBetweenTwoDateBreaks: Int = 5 - public static func bodyTextColor(isOutgoing: Bool) -> ThemeValue { return (isOutgoing ? .messageBubble_outgoingText : @@ -735,36 +768,24 @@ extension MessageViewModel { ) } - /// Returns the difference in minutes, ignoring seconds - /// - /// If both dates are the same date, returns 0 - /// If firstDate is one minute before secondDate, returns 1 - /// - /// **Note:** Assumes both dates use the "current" calendar - private static func minutesFrom(_ firstDate: Date, to secondDate: Date) -> Int? { - let calendar: Calendar = Calendar.current - let components1: DateComponents = calendar.dateComponents( - [.era, .year, .month, .day, .hour, .minute], - from: firstDate - ) - let components2: DateComponents = calendar.dateComponents( - [.era, .year, .month, .day, .hour, .minute], - from: secondDate - ) + fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { + let diff: Int64 = abs(timestamp2 - timestamp1) + let fiveMinutesInMs: Int64 = (5 * 60 * 1000) - guard - let date1: Date = calendar.date(from: components1), - let date2: Date = calendar.date(from: components2) - else { return nil } + /// If there is more than 5 minutes between the timestamps then we should show a date break + if diff > fiveMinutesInMs { + return true + } - return calendar.dateComponents([.minute], from: date1, to: date2).minute - } - - fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool { - let date1: Date = Date(timeIntervalSince1970: TimeInterval(Double(timestamp1) / 1000)) - let date2: Date = Date(timeIntervalSince1970: TimeInterval(Double(timestamp2) / 1000)) + /// If we crossed midnight then we want to show a date break regardless of how much time has passed - do this by shifting the + /// timestamps to local time (using the current timezone) and getting a "day number" to check if they are the same dat + let seconds1: Int = Int(timestamp1 / 1000) + let seconds2: Int = Int(timestamp2 / 1000) + let offset: Int = TimeZone.current.secondsFromGMT() + let day1: Int = ((seconds1 + offset) / 86400) + let day2: Int = ((seconds2 + offset) / 86400) - return ((minutesFrom(date1, to: date2) ?? 0) > maxMinutesBetweenTwoDateBreaks) + return (day1 != day2) } } @@ -782,7 +803,7 @@ public extension MessageViewModel { }() static func quotedInteractionIds( - for originalInteractionIds: [Int64], + for originalInteractionIds: Set, currentUserSessionIds: Set ) -> SQLRequest> { let interaction: TypedTableAlias = TypedTableAlias() @@ -812,10 +833,12 @@ public extension MessageViewModel { extension MessageViewModel { public func createUserProfileModalInfo( + openGroupServer: String?, + openGroupPublicKey: String?, onStartThread: (@MainActor () -> Void)?, onProBadgeTapped: (@MainActor () -> Void)?, using dependencies: Dependencies - ) -> UserProfileModal.Info? { + ) async -> UserProfileModal.Info? { let (info, _) = ProfilePictureView.Info.generateInfoFrom( size: .hero, publicKey: authorId, @@ -827,27 +850,31 @@ extension MessageViewModel { guard let profileInfo: ProfilePictureView.Info = info else { return nil } - let qrCodeImage: UIImage? = { - let targetId: String = (authorUnblindedId ?? authorId) - - switch try? SessionId.Prefix(from: targetId) { - case .none, .blinded15, .blinded25, .versionBlinded07, .group, .unblinded: return nil - case .standard: - return QRCode.generate( - for: targetId, - hasBackground: false, - iconName: "SessionWhite40" // stringlint:ignore - ) - } - }() - let sessionId: String? = { + let sessionId: String? = await { if let unblindedId: String = authorUnblindedId { return unblindedId } switch try? SessionId.Prefix(from: authorId) { - case .none, .blinded15, .blinded25, .versionBlinded07, .group, .unblinded: return nil case .standard: return authorId + case .none, .versionBlinded07, .group, .unblinded: return nil + case .blinded15, .blinded25: + /// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact and use that, + /// otherwise just use the blinded id + guard let openGroupServer, let openGroupPublicKey else { return nil } + + let maybeLookup: BlindedIdLookup? = try? await dependencies[singleton: .storage].writeAsync { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + + return maybeLookup?.sessionId } }() let blindedId: String? = { @@ -856,6 +883,15 @@ extension MessageViewModel { case .blinded15, .blinded25: return authorId } }() + let qrCodeImage: UIImage? = { + guard let sessionId else { return nil } + + return QRCode.generate( + for: sessionId, + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + }() return UserProfileModal.Info( sessionId: sessionId, @@ -925,70 +961,272 @@ private extension MessageViewModel { } } -private extension Interaction { - func body( - threadId: String, - threadVariant: SessionThread.Variant, - threadContactDisplayName: String?, - authorDisplayName: String, - attachments: [Attachment]?, - linkPreview: LinkPreview?, - using dependencies: Dependencies - ) -> String? { - guard variant.isInfoMessage else { return body } +internal extension Interaction { + struct ContentBuilder { + private let interaction: Interaction + private let searchText: String? + private let dataCache: ConversationDataCache - /// Info messages might not have a body so we should use the 'previewText' value instead - return Interaction.previewText( - variant: variant, - body: body, - threadContactDisplayName: (threadContactDisplayName ?? ""), - authorDisplayName: authorDisplayName, - attachmentDescriptionInfo: attachments?.first.map { firstAttachment in - Attachment.DescriptionInfo( - id: firstAttachment.id, - variant: firstAttachment.variant, - contentType: firstAttachment.contentType, - sourceFilename: firstAttachment.sourceFilename + private let threadVariant: SessionThread.Variant + private let currentUserSessionIds: Set + public let attachments: [Attachment] + public let linkPreview: LinkPreview? + public let linkPreviewAttachment: Attachment? + + public var rawBody: String? { interaction.body } + public let authorDisplayName: String + public let authorDisplayNameNoSuffix: String + public let threadContactDisplayName: String + public var containsOnlyEmoji: Bool { interaction.body?.containsOnlyEmoji == true } + public var glyphCount: Int { interaction.body?.glyphCount ?? 0 } + + init( + interaction: Interaction, + threadVariant: SessionThread.Variant, + searchText: String? = nil, + dataCache: ConversationDataCache + ) { + self.interaction = interaction + self.searchText = searchText + self.dataCache = dataCache + + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: interaction.threadId) + let linkPreviewInfo = ContentBuilder.resolveBestLinkPreview( + for: interaction, + dataCache: dataCache + ) + self.threadVariant = threadVariant + self.currentUserSessionIds = currentUserSessionIds + self.attachments = (interaction.id.map { dataCache.attachments(for: $0) } ?? []) + self.linkPreview = linkPreviewInfo?.preview + self.linkPreviewAttachment = linkPreviewInfo?.attachment + + if currentUserSessionIds.contains(interaction.authorId) { + self.authorDisplayName = "you".localized() + self.authorDisplayNameNoSuffix = "you".localized() + } + else { + let profile: Profile = ( + dataCache.profile(for: interaction.authorId) ?? + Profile.defaultFor(interaction.authorId) ) - }, - attachmentCount: attachments?.count, - isOpenGroupInvitation: (linkPreview?.variant == .openGroupInvitation), - using: dependencies - ) - } - - func linkPreview( - linkPreviewCache: [String: [LinkPreview]], - attachmentCache: [String: Attachment], - ) -> (preview: LinkPreview, attachment: Attachment?)? { - let preview: LinkPreview? = linkPreviewUrl.map { url -> LinkPreview? in + + self.authorDisplayName = profile.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + self.authorDisplayNameNoSuffix = profile.displayName(includeSessionIdSuffix: false) + } + + self.threadContactDisplayName = dataCache.contactDisplayName(for: interaction.threadId) + } + + func makeBubbleBody() -> String? { + if interaction.variant.isInfoMessage { + return makePreviewText() + } + + guard let rawBody: String = interaction.body, !rawBody.isEmpty else { + return nil + } + + /// No need to process mentions if the preview doesn't contain the mention prefix + guard rawBody.contains("@") else { return rawBody } + + let isOutgoing: Bool = (interaction.variant == .standardOutgoing) + + return MentionUtilities.taggingMentions( + in: rawBody, + location: (isOutgoing ? .outgoingMessage : .incomingMessage), + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: dataCache.displayNameRetriever( + for: interaction.threadId, + includeSessionIdSuffixWhenInMessageBody: (threadVariant == .community) + ) + ) + } + + func makeBodyForCopying() -> String? { + if interaction.variant.isInfoMessage { + return makePreviewText() + } + + return rawBody + } + + func makePreviewText() -> String? { + return Interaction.previewText( + variant: interaction.variant, + body: interaction.body, + threadContactDisplayName: threadContactDisplayName, + authorDisplayName: authorDisplayName, + attachmentDescriptionInfo: attachments.first.map { firstAttachment in + Attachment.DescriptionInfo( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: attachments.count, + isOpenGroupInvitation: (linkPreview?.variant == .openGroupInvitation) + ) + } + + func makeSnippet(dateNow: Date) -> String? { + var result: String = "" + let isSearchResult: Bool = (searchText != nil) + let groupInfo: LibSession.GroupInfo? = dataCache.groupInfo(for: interaction.threadId) + let groupKicked: Bool = (groupInfo?.wasKickedFromGroup == true) + let groupDestroyed: Bool = (groupInfo?.wasGroupDestroyed == true) + let groupThreadTypes: Set = [.legacyGroup, .group, .community] + let groupSourceTypes: Set = [.conversationList, .searchResults] + let shouldIncludeAuthorPrefix: Bool = ( + !interaction.variant.isInfoMessage && + groupSourceTypes.contains(dataCache.context.source) && + groupThreadTypes.contains(threadVariant) + ) + + /// Add status icon prefixes (these are only needed in the conversation list) + if dataCache.context.isConversationList && !isSearchResult && !groupKicked && !groupDestroyed { + if let thread = dataCache.thread(for: interaction.threadId) { + let now: TimeInterval = dateNow.timeIntervalSince1970 + let mutedUntil: TimeInterval = (thread.mutedUntilTimestamp ?? 0) + + if now < mutedUntil { + result.append(NotificationsUI.mutePrefix.rawValue) + result.append(" ") + } + else if thread.onlyNotifyForMentions { + result.append(NotificationsUI.mentionPrefix.rawValue) + result.append(" ") /// Need a double space here + } + } + } + + /// If it's a group conversation then it might have a specia status + switch (groupInfo, groupDestroyed, groupKicked, interaction.variant) { + case (.some(let groupInfo), true, _, _): + result.append( + "groupDeletedMemberDescription" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + case (.some(let groupInfo), _, true, _): + result.append( + "groupRemovedYou" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + case (.some(let groupInfo), _, _, .infoGroupCurrentUserErrorLeaving): + result.append( + "groupLeaveErrorFailed" + .put(key: "group_name", value: groupInfo.name) + .localizedDeformatted() + ) + + default: + if let previewText: String = makePreviewText() { + let finalPreviewText: String = (!previewText.contains("@") ? + previewText : + MentionUtilities.resolveMentions( + in: previewText, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: dataCache.displayNameRetriever( + for: interaction.threadId, + includeSessionIdSuffixWhenInMessageBody: (threadVariant == .community) + ) + ) + ) + + /// The search term highlighting logic will add the author directly (so it doesn't get highlighted) + if !isSearchResult && shouldIncludeAuthorPrefix { + result.append( + "messageSnippetGroup" + .put(key: "author", value: authorDisplayName) + .put(key: "message_snippet", value: finalPreviewText) + .localizedDeformatted() + ) + } + else { + result.append(finalPreviewText) + } + } + } + + guard !result.isEmpty else { return nil } + + /// If we don't have a search term then return the value, otherwise highlight the search term tokens + guard let searchText: String = searchText else { + return result + } + + return GlobalSearch.highlightSearchText( + searchText: searchText, + content: result, + authorName: (shouldIncludeAuthorPrefix ? authorDisplayName : nil) + ) + } + + private static func resolveBestLinkPreview( + for interaction: Interaction, + dataCache: ConversationDataCache + ) -> (preview: LinkPreview, attachment: Attachment?)? { + guard let url: String = interaction.linkPreviewUrl else { return nil } + /// Find all previews for the given url and sort by newest to oldest - guard let possiblePreviews: [LinkPreview] = linkPreviewCache[url]?.sorted(by: { lhs, rhs in - guard lhs.timestamp != rhs.timestamp else { + let possiblePreviews: Set = dataCache.linkPreviews(for: url) + + guard !possiblePreviews.isEmpty else { return nil } + + /// Try get the link preview for the time the message was sent + let sentTimestamp: TimeInterval = (TimeInterval(interaction.timestampMs) / 1000) + let minTimestamp: TimeInterval = (sentTimestamp - LinkPreview.timstampResolution) + let maxTimestamp: TimeInterval = (sentTimestamp + LinkPreview.timstampResolution) + var bestFallback: LinkPreview? = nil + var bestInWindow: LinkPreview? = nil + + for preview in possiblePreviews { + /// Evaluate the `bestFallback` (used if we can't find a `bestInWindow`) + if let currentFallback: LinkPreview = bestFallback { /// If the timestamps match then it's likely there is an optimistic link preview in the cache, so if one of the options /// has an `attachmentId` then we should prioritise that one - switch (lhs.attachmentId, rhs.attachmentId) { - case (.some, .none): return true - case (.none, .some): return false - case (.some, .some), (.none, .none): return true /// Whatever was added to the cache first wins + switch (preview.attachmentId, currentFallback.attachmentId) { + case (.some, .none): bestFallback = preview + case (.none, .some): break + case (.some, .some), (.none, .none): + /// If this preview is newer than the `currentFallback` then use it instead + if preview.timestamp > currentFallback.timestamp { + bestFallback = preview + } } } - return lhs.timestamp > rhs.timestamp - }) else { return nil } - - /// Try get the link preview for the time the message was sent - let minTimestamp: TimeInterval = (TimeInterval(timestampMs / 1000) - LinkPreview.timstampResolution) - let maxTimestamp: TimeInterval = (TimeInterval(timestampMs / 1000) + LinkPreview.timstampResolution) - let targetPreview: LinkPreview? = possiblePreviews.first { - $0.timestamp > minTimestamp && - $0.timestamp < maxTimestamp + /// Evaluate the `bestInWindow` + if preview.timestamp > minTimestamp && preview.timestamp < maxTimestamp { + if let currentInWindow: LinkPreview = bestInWindow { + /// If the timestamps match then it's likely there is an optimistic link preview in the cache, so if one of the options + /// has an `attachmentId` then we should prioritise that one + switch (preview.attachmentId, currentInWindow.attachmentId) { + case (.some, .none): bestInWindow = preview + case (.none, .some): break + case (.some, .some), (.none, .none): + /// If this preview is newer than the `currentInWindow` then use it instead + if preview.timestamp > currentInWindow.timestamp { + bestInWindow = preview + } + } + } + else { + bestInWindow = preview + } + } } - /// Fallback to the newest preview - return (targetPreview ?? possiblePreviews.first) + guard let finalPreview: LinkPreview = (bestInWindow ?? bestFallback) else { return nil } + + return (finalPreview, finalPreview.attachmentId.map { dataCache.attachment(for: $0) }) } - - return preview.map { ($0, $0.attachmentId.map { attachmentCache[$0] }) } } } diff --git a/SessionMessagingKit/Shared Models/Position.swift b/SessionMessagingKit/Types/Position.swift similarity index 100% rename from SessionMessagingKit/Shared Models/Position.swift rename to SessionMessagingKit/Types/Position.swift diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index 3a6abf647c..d8332bef71 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -8,15 +8,37 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { + static func standard(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) -> AuthenticationMethod { + return Standard( + sessionId: sessionId, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey + ) + } + + static func groupAdmin(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) -> AuthenticationMethod { + return GroupAdmin( + groupSessionId: groupSessionId, + ed25519SecretKey: ed25519SecretKey + ) + } + + static func groupMember(groupSessionId: SessionId, authData: Data) -> AuthenticationMethod { + return GroupMember( + groupSessionId: groupSessionId, + authData: authData + ) + } + /// Used when interacting as the current user - struct standard: AuthenticationMethod { + struct Standard: AuthenticationMethod { public let sessionId: SessionId public let ed25519PublicKey: [UInt8] public let ed25519SecretKey: [UInt8] public var info: Info { .standard(sessionId: sessionId, ed25519PublicKey: ed25519PublicKey) } - public init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { + fileprivate init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { self.sessionId = sessionId self.ed25519PublicKey = ed25519PublicKey self.ed25519SecretKey = ed25519SecretKey @@ -32,13 +54,13 @@ public extension Authentication { } /// Used when interacting as a group admin - struct groupAdmin: AuthenticationMethod { + struct GroupAdmin: AuthenticationMethod { public let groupSessionId: SessionId public let ed25519SecretKey: [UInt8] public var info: Info { .groupAdmin(groupSessionId: groupSessionId, ed25519SecretKey: ed25519SecretKey) } - public init(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) { + fileprivate init(groupSessionId: SessionId, ed25519SecretKey: [UInt8]) { self.groupSessionId = groupSessionId self.ed25519SecretKey = ed25519SecretKey } @@ -53,13 +75,13 @@ public extension Authentication { } /// Used when interacting as a group member - struct groupMember: AuthenticationMethod { + struct GroupMember: AuthenticationMethod { public let groupSessionId: SessionId public let authData: Data public var info: Info { .groupMember(groupSessionId: groupSessionId, authData: authData) } - public init(groupSessionId: SessionId, authData: Data) { + fileprivate init(groupSessionId: SessionId, authData: Data) { self.groupSessionId = groupSessionId self.authData = authData } @@ -82,12 +104,7 @@ public extension Authentication { // MARK: - Convenience -fileprivate struct GroupAuthData: Codable, FetchableRecord { - let groupIdentityPrivateKey: Data? - let authData: Data? -} - -public extension Authentication.community { +public extension Authentication.Community { init(info: LibSession.OpenGroupCapabilityInfo, forceBlinded: Bool = false) { self.init( roomToken: info.roomToken, @@ -114,7 +131,7 @@ public extension Authentication { .fetchOne(db, server: server, activelyPollingOnly: activelyPollingOnly) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) } static func with( @@ -132,7 +149,7 @@ public extension Authentication { .fetchOne(db, id: threadId) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) case (.contact, .blinded15), (.contact, .blinded25): guard @@ -141,14 +158,13 @@ public extension Authentication { .fetchOne(db, server: lookup.openGroupServer) else { throw CryptoError.invalidAuthentication } - return Authentication.community(info: info, forceBlinded: forceBlinded) + return Authentication.Community(info: info, forceBlinded: forceBlinded) - default: return try Authentication.with(db, swarmPublicKey: threadId, using: dependencies) + default: return try Authentication.with(swarmPublicKey: threadId, using: dependencies) } } static func with( - _ db: ObservingDatabase, swarmPublicKey: String, using dependencies: Dependencies ) throws -> AuthenticationMethod { @@ -167,13 +183,11 @@ public extension Authentication { ) case .some(let sessionId) where sessionId.prefix == .group: - let authData: GroupAuthData? = try? ClosedGroup - .filter(id: swarmPublicKey) - .select(.authData, .groupIdentityPrivateKey) - .asRequest(of: GroupAuthData.self) - .fetchOne(db) + let authData: GroupAuthData = dependencies.mutate(cache: .libSession) { libSession in + libSession.authData(groupSessionId: SessionId(.group, hex: swarmPublicKey)) + } - switch (authData?.groupIdentityPrivateKey, authData?.authData) { + switch (authData.groupIdentityPrivateKey, authData.authData) { case (.some(let privateKey), _): return Authentication.groupAdmin( groupSessionId: sessionId, diff --git a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift index a6e5ab84fc..913fddcf4a 100644 --- a/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/MentionSelectionView+SessionMessagingKit.swift @@ -46,7 +46,7 @@ public extension MentionSelectionView.ViewModel { using dependencies: Dependencies ) async throws -> [MentionSelectionView.ViewModel] { let profiles: [Profile] = try await dependencies[singleton: .storage].readAsync { db in - let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) + let pattern: FTS5Pattern? = try? GlobalSearch.pattern(db, searchTerm: query, forTable: Profile.self) let targetPrefixes: [SessionId.Prefix] = { switch threadVariant { case .contact, .legacyGroup, .group: return [.standard] diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index c05fb2b1e0..4077e87011 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -84,6 +84,8 @@ public extension ObservableKey { static func attachmentDeleted(id: String, messageId: Int64?) -> ObservableKey { ObservableKey("attachmentDeleted-\(id)-\(messageId.map { "\($0)" } ?? "NULL")", .attachmentDeleted) } + + static let recentReactionsUpdated: ObservableKey = "recentReactionsUpdated" static func reactionsChanged(messageId: Int64) -> ObservableKey { ObservableKey("reactionsChanged-\(messageId)", .reactionsChanged) } @@ -97,6 +99,10 @@ public extension ObservableKey { // MARK: - Groups + static func groupInfo(groupId: String) -> ObservableKey { + ObservableKey("groupInfo-\(groupId)", .groupInfo) + } + static func groupMemberCreated(threadId: String) -> ObservableKey { ObservableKey("groupMemberCreated-\(threadId)", .groupMemberCreated) } @@ -132,6 +138,7 @@ public extension GenericObservableKey { static let attachmentDeleted: GenericObservableKey = "attachmentDeleted" static let reactionsChanged: GenericObservableKey = "reactionsChanged" + static let groupInfo: GenericObservableKey = "groupInfo" static let groupMemberCreated: GenericObservableKey = "groupMemberCreated" static let groupMemberUpdated: GenericObservableKey = "groupMemberUpdated" static let groupMemberDeleted: GenericObservableKey = "groupMemberDeleted" @@ -260,6 +267,7 @@ public extension ObservingDatabase { public struct ConversationEvent: Hashable { public let id: String + public let variant: SessionThread.Variant public let change: Change? public enum Change: Hashable { @@ -271,15 +279,15 @@ public struct ConversationEvent: Hashable { case mutedUntilTimestamp(TimeInterval?) case onlyNotifyForMentions(Bool) case markedAsUnread(Bool) - case unreadCount - case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) case draft(String?) + case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) + case unreadCount } } public extension ObservingDatabase { - func addConversationEvent(id: String, type: CRUDEvent) { - let event: ConversationEvent = ConversationEvent(id: id, change: type.change) + func addConversationEvent(id: String, variant: SessionThread.Variant, type: CRUDEvent) { + let event: ConversationEvent = ConversationEvent(id: id, variant: variant, change: type.change) switch type { case .created: addEvent(ObservedEvent(key: .conversationCreated, value: event)) diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 1fb01999ee..ea3aea4e25 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -411,7 +411,11 @@ public extension Profile { let newDisplayName: String = effectiveDisplayName, newDisplayName != (isCurrentUser ? profile.name : (profile.nickname ?? profile.name)) { - db.addConversationEvent(id: publicKey, type: .updated(.displayName(newDisplayName))) + db.addConversationEvent( + id: publicKey, + variant: .contact, + type: .updated(.displayName(newDisplayName)) + ) } /// If the profile was either updated or matches the current (latest) state then we should check if we have the display picture on diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 12497fca5f..d1799a7544 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -298,6 +298,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [groupSessionId]) } + func groupInfo(for groupIds: Set) -> [LibSession.GroupInfo?] { + return mock(args: [groupIds]) + } + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return mock(args: [groupSessionId]) } @@ -305,6 +309,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return mock(args: [groupSessionId]) } + + func authData(groupSessionId: SessionId) -> GroupAuthData { + return mock(args: [groupSessionId]) + } } // MARK: - Convenience diff --git a/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift b/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift index 92862325b1..ff332b79b5 100644 --- a/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift +++ b/SessionNetworkingKitTests/SOGS/Crypto/Authentication+SOGS.swift @@ -6,8 +6,26 @@ import SessionUtilitiesKit // MARK: - Authentication Types public extension Authentication { + static func community( + roomToken: String, + server: String, + publicKey: String, + hasCapabilities: Bool, + supportsBlinding: Bool, + forceBlinded: Bool = false + ) -> AuthenticationMethod { + return Community( + roomToken: roomToken, + server: server, + publicKey: publicKey, + hasCapabilities: hasCapabilities, + supportsBlinding: supportsBlinding, + forceBlinded: forceBlinded + ) + } + /// Used when interacting with a community - struct community: AuthenticationMethod { + struct Community: AuthenticationMethod { public let roomToken: String public let server: String public let publicKey: String @@ -31,7 +49,7 @@ public extension Authentication { publicKey: String, hasCapabilities: Bool, supportsBlinding: Bool, - forceBlinded: Bool = false + forceBlinded: Bool ) { self.roomToken = roomToken self.server = server diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 7d3c4d4047..aa7b92ceb5 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -92,22 +92,22 @@ final class SimplifiedConversationCell: UITableViewCell { // MARK: - Updating - public func update(with cellViewModel: SessionThreadViewModel, using dependencies: Dependencies) { - accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) + public func update(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { + accentLineView.alpha = (cellViewModel.isBlocked ? 1 : 0) profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) profilePictureView.update( - publicKey: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - displayPictureUrl: cellViewModel.threadDisplayPictureUrl, + publicKey: cellViewModel.id, + threadVariant: cellViewModel.variant, + displayPictureUrl: cellViewModel.displayPictureUrl, profile: cellViewModel.profile, additionalProfile: cellViewModel.additionalProfile, using: dependencies ) - displayNameLabel.text = cellViewModel.displayName - displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) + displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge self.isAccessibilityElement = true self.accessibilityIdentifier = "Contact" - self.accessibilityLabel = cellViewModel.displayName + self.accessibilityLabel = cellViewModel.displayName.deformatted() } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 9c55e9c0f9..ba6f37e120 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -170,7 +170,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView dataChangeObservable = nil } - private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { + private func handleUpdates(_ updatedViewData: [ConversationInfoViewModel]) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) guard hasLoadedInitialData else { @@ -224,12 +224,12 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController( mode: .modal, delegate: self, - threadId: viewModel.viewData[indexPath.row].threadId, - threadVariant: viewModel.viewData[indexPath.row].threadVariant, + threadId: viewModel.viewData[indexPath.row].id, + threadVariant: viewModel.viewData[indexPath.row].variant, attachments: attachments, messageText: nil, quoteViewModel: nil, - disableLinkPreviewImageDownload: (viewModel.viewData[indexPath.row].threadCanUpload != true), + disableLinkPreviewImageDownload: !viewModel.viewData[indexPath.row].canUpload, didLoadLinkPreview: { [weak self] result in self?.viewModel.didLoadLinkPreview(result: result) }, @@ -350,6 +350,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try SessionThread.updateVisibility( db, threadId: threadId, + threadVariant: thread.variant, isVisible: true, additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], using: dependencies diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index cf279e19b7..5aca17caa4 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -43,7 +43,7 @@ public class ThreadPickerViewModel { // MARK: - Content /// This value is the current state of the view - public private(set) var viewData: [SessionThreadViewModel] = [] + public private(set) var viewData: [ConversationInfoViewModel] = [] /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance @@ -56,43 +56,67 @@ public class ThreadPickerViewModel { /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this public lazy var observableViewData = ValueObservation - .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId + .trackingConstantRegion { [dependencies] db -> ([String], ConversationDataCache) in + var dataCache: ConversationDataCache = ConversationDataCache( + userSessionId: dependencies[cache: .general].sessionId, + context: ConversationDataCache.Context( + source: .conversationList, + requireFullRefresh: true, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ) + let fetchRequirements: ConversationDataHelper.FetchRequirements = ConversationDataHelper.determineFetchRequirements( + for: .empty, + currentCache: dataCache, + itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel](), + loadPageEvent: .initial + ) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + + /// Fetch any required data from the cache + var loadResult: PagedData.LoadResult = PagedData.LoadedInfo( + record: SessionThread.self, + pageSize: Int.max, + requiredJoinSQL: ConversationInfoViewModel.requiredJoinSQL, + filterSQL: ConversationInfoViewModel.homeFilterSQL(userSessionId: dataCache.userSessionId), + groupSQL: nil, + orderSQL: ConversationInfoViewModel.homeOrderSQL + ).asResult + (loadResult, dataCache) = try ConversationDataHelper.fetchFromDatabase( + ObservingDatabase.create(db, using: dependencies), + requirements: fetchRequirements, + currentCache: dataCache, + loadResult: loadResult, + loadPageEvent: .initial, + using: dependencies + ) - return try SessionThreadViewModel - .shareQuery(userSessionId: userSessionId) - .fetchAll(db) - .map { threadViewModel in - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard threadViewModel.threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: threadViewModel.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) - } - }() + return (loadResult.info.currentIds, dataCache) + } + .map { [dependencies, hasNonTextAttachment] threadIds, dataCache -> [ConversationInfoViewModel] in + threadIds + .compactMap { id in + guard let thread: SessionThread = dataCache.thread(for: id) else { return nil } - return threadViewModel.populatingPostQueryData( - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: threadViewModel.determineInitialCanUploadFlag(using: dependencies) + return ConversationInfoViewModel( + thread: thread, + dataCache: dataCache, + using: dependencies + ) + } + .filter { + $0.canWrite && ( /// Exclude unwritable threads + $0.canUpload == true || /// Exclude ununploadable threads unleass we only include text-based attachments + !hasNonTextAttachment ) } - } - .map { [dependencies, hasNonTextAttachment] threads -> [SessionThreadViewModel] in - threads.filter { - $0.threadCanWrite == true && ( /// Exclude unwritable threads - $0.threadCanUpload == true || /// Exclude ununploadable threads unleass we only include text-based attachments - !hasNonTextAttachment - ) - } } .removeDuplicates() .handleEvents(didFail: { Log.error("Observation failed with error: \($0)") }) @@ -106,7 +130,7 @@ public class ThreadPickerViewModel { } } - public func updateData(_ updatedData: [SessionThreadViewModel]) { + public func updateData(_ updatedData: [ConversationInfoViewModel]) { self.viewData = updatedData } } diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift index e6b803478a..9a791d88a9 100644 --- a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -149,24 +149,22 @@ public struct QuoteViewModel: Sendable, Equatable, Hashable { } }() - self.attributedText = MentionUtilities.highlightMentions( - in: text, - currentUserSessionIds: currentUserSessionIds, - location: { - switch (mode, direction) { - case (.draft, _): return .quoteDraft - case (_, .outgoing): return .outgoingQuote - case (_, .incoming): return .incomingQuote - } - }(), - textColor: targetThemeColor, - attributes: [ - .themeForegroundColor: targetThemeColor, - .font: UIFont.systemFont(ofSize: Values.smallFontSize) - ], - displayNameRetriever: displayNameRetriever, - currentUserMentionImage: currentUserMentionImage - ) + self.attributedText = text + .formatted( + baseFont: .systemFont(ofSize: Values.smallFontSize), + attributes: [.themeForegroundColor: targetThemeColor], + mentionColor: MentionUtilities.mentionColor( + textColor: targetThemeColor, + location: { + switch (mode, direction) { + case (.draft, _): return .quoteDraft + case (_, .outgoing): return .outgoingQuote + case (_, .incoming): return .incomingQuote + } + }() + ), + currentUserMentionImage: currentUserMentionImage + ) } public init(showYouAsAuthor: Bool, previewBody: String) { diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index f506dab556..e98244529e 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -227,17 +227,14 @@ public enum ThemeManager { } @MainActor public static func onThemeChange(observer: AnyObject, callback: @escaping @MainActor (Theme, Theme.PrimaryColor, (ThemeValue) -> UIColor?) -> ()) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: observer), - info: [] - ) { theme in - callback(theme, syncState.state.primaryColor, { value -> UIColor? in - ThemeManager.color(for: value, in: theme, with: syncState.state.primaryColor) - }) - }, - forKey: observer - ) + ThemeManager.storeAndApply( + observer, + info: [] + ) { theme in + callback(theme, syncState.state.primaryColor, { value -> UIColor? in + ThemeManager.color(for: value, in: theme, with: syncState.state.primaryColor) + }) + } } internal static func color( @@ -317,28 +314,40 @@ public enum ThemeManager { } } + @MainActor internal static func storeAndApply( + _ view: T, + info: [ThemeApplier.Info], + applyTheme: @escaping @MainActor (Theme) -> () + ) { + let applier: ThemeApplier = ThemeApplier( + existingApplier: ThemeManager.get(for: view), + info: info, + applyTheme: applyTheme + ) + ThemeManager.uiRegistry.setObject(applier, forKey: view) + applier.performInitialApplicationIfNeeded() + } + @MainActor internal static func set( _ view: T, keyPath: ReferenceWritableKeyPath, - to value: ThemeValue? + to value: ThemeValue?, + as info: ThemeApplier.Info = .other ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let value: ThemeValue = value else { - view?[keyPath: keyPath] = nil - return - } + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .color(value), info ] + ) { [weak view] theme in + guard let value: ThemeValue = value else { + view?[keyPath: keyPath] = nil + return + } - let currentState: ThemeState = syncState.state - view?[keyPath: keyPath] = ThemeManager.resolvedColor( - ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) - ) - }, - forKey: view - ) + let currentState: ThemeState = syncState.state + view?[keyPath: keyPath] = ThemeManager.resolvedColor( + ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) + ) + } } internal static func remove( @@ -346,7 +355,7 @@ public enum ThemeManager { keyPath: ReferenceWritableKeyPath ) { // Note: Need to explicitly remove (setting to 'nil' won't actually remove it) - guard let updatedApplier: ThemeApplier = ThemeManager.get(for: view)?.removing(allWith: keyPath) else { + guard let updatedApplier: ThemeApplier = ThemeManager.get(for: view)?.removing(allWith: .keyPath(keyPath)) else { ThemeManager.uiRegistry.removeObject(forKey: view) return } @@ -357,25 +366,23 @@ public enum ThemeManager { @MainActor internal static func set( _ view: T, keyPath: ReferenceWritableKeyPath, - to value: ThemeValue? + to value: ThemeValue?, + as info: ThemeApplier.Info = .other ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let value: ThemeValue = value else { - view?[keyPath: keyPath] = nil - return - } - - let currentState: ThemeState = syncState.state - view?[keyPath: keyPath] = ThemeManager.resolvedColor( - ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) - )?.cgColor - }, - forKey: view - ) + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .color(value), info ] + ) { [weak view] theme in + guard let value: ThemeValue = value else { + view?[keyPath: keyPath] = nil + return + } + + let currentState: ThemeState = syncState.state + view?[keyPath: keyPath] = ThemeManager.resolvedColor( + ThemeManager.color(for: value, in: currentState.theme, with: currentState.primaryColor) + )?.cgColor + } } internal static func remove( @@ -384,7 +391,7 @@ public enum ThemeManager { ) { ThemeManager.uiRegistry.setObject( ThemeManager.get(for: view)? - .removing(allWith: keyPath), + .removing(allWith: .keyPath(keyPath)), forKey: view ) } @@ -394,49 +401,89 @@ public enum ThemeManager { keyPath: ReferenceWritableKeyPath, to value: ThemedAttributedString? ) { - ThemeManager.uiRegistry.setObject( - ThemeApplier( - existingApplier: ThemeManager.get(for: view), - info: [ keyPath ] - ) { [weak view] theme in - guard let originalThemedString: ThemedAttributedString = value else { - view?[keyPath: keyPath] = nil - return - } + ThemeManager.storeAndApply( + view, + info: [ .keyPath(keyPath), .other ] + ) { [weak view] theme in + guard let originalThemedString: ThemedAttributedString = value else { + view?[keyPath: keyPath] = nil + return + } + + let newAttrString: NSMutableAttributedString = NSMutableAttributedString() + let originalAttrString: NSAttributedString = originalThemedString.attributedString + let fullRange: NSRange = NSRange(location: 0, length: originalAttrString.length) + let currentState: ThemeState = syncState.state + + originalAttrString.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in + var newAttributes: [NSAttributedString.Key: Any] = attributes + var foundTextColor: Bool = false - let newAttrString: NSMutableAttributedString = NSMutableAttributedString() - let originalAttrString: NSAttributedString = originalThemedString.attributedString - let fullRange: NSRange = NSRange(location: 0, length: originalAttrString.length) - let currentState: ThemeState = syncState.state + /// Retrieve our custom alpha multiplier attribute + let alphaMultiplier: CGFloat = ((newAttributes[.themeAlphaMultiplier] as? CGFloat) ?? 1.0) + newAttributes.removeValue(forKey: .themeAlphaMultiplier) - originalAttrString.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in - var newAttributes: [NSAttributedString.Key: Any] = attributes + /// Convert any of our custom attributes to their normal ones + NSAttributedString.Key.themedKeys.forEach { key in + guard let themeValue: ThemeValue = newAttributes[key] as? ThemeValue else { return } + newAttributes.removeValue(forKey: key) - /// Convert any of our custom attributes to their normal ones - NSAttributedString.Key.themedKeys.forEach { key in - guard let themeValue: ThemeValue = newAttributes[key] as? ThemeValue else { - return - } - - newAttributes.removeValue(forKey: key) - - guard - let originalKey = key.originalKey, - let color = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) as UIColor? - else { return } - - newAttributes[originalKey] = ThemeManager.resolvedColor(color) - } + guard + let originalKey: NSAttributedString.Key = key.originalKey, + let color: UIColor = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) + else { return } - /// Add the themed substring to `newAttrString` - let substring: String = originalAttrString.attributedSubstring(from: range).string - newAttrString.append(NSAttributedString(string: substring, attributes: newAttributes)) + foundTextColor = true + newAttributes[originalKey] = (alphaMultiplier < 1 ? + ThemeManager.resolvedColor(color)?.withAlphaComponent(alphaMultiplier): + ThemeManager.resolvedColor(color) + ) } - view?[keyPath: keyPath] = ThemedAttributedString(attributedString: newAttrString) - }, - forKey: view - ) + /// If we didn't find an explicit text color but have set `themeAlphaMultiplier` then we need to try to extract + /// the current text color from the component and add that + if !foundTextColor && alphaMultiplier < 1 { + let maybeThemeValue: ThemeValue? = view + .map { ThemeManager.get(for: $0) }? + .map { $0.allInfo } + .map { $0.first(where: { $0.contains(.textColor) }) }? + .map { $0.compactMap { $0.storedValue as? ThemeValue } }? + .first + + if + let originalKey: NSAttributedString.Key = .themeForegroundColor.originalKey, + let themeValue: ThemeValue = maybeThemeValue, + let color: UIColor = ThemeManager.color(for: themeValue, in: currentState.theme, with: currentState.primaryColor) + { + newAttributes[originalKey] = ThemeManager.resolvedColor(color)? + .withAlphaComponent(alphaMultiplier) + } + } + + let newAttrSubstring: NSAttributedString + let substring: String = originalAttrString.attributedSubstring(from: range).string + + /// Retrieve our custom user mention image attribute + /// + /// If this has been set then we actually want to entirely replace the tagged content with an image attachment + if let currentUserMentionImage: UIImage = newAttributes[.themeCurrentUserMentionImage] as? UIImage { + newAttrSubstring = MentionUtilities.currentUserMentionImageString( + substring: substring, + currentUserMentionImage: currentUserMentionImage + ) + newAttributes.removeValue(forKey: .themeCurrentUserMentionImage) + } + else { + /// Otherwise we can just extract the raw string from the original + newAttrSubstring = NSAttributedString(string: substring, attributes: newAttributes) + } + + /// Add the themed substring to `newAttrString` + newAttrString.append(newAttrSubstring) + } + + view?[keyPath: keyPath] = ThemedAttributedString(attributedString: newAttrString) + } } internal static func set( @@ -512,18 +559,35 @@ private final class ThemeManagerSyncState { // MARK: - ThemeApplier internal class ThemeApplier { - enum InfoKey: String { - case keyPath - case controlState + enum Info: Equatable { + case keyPath(AnyHashable) + case state(UIControl.State) + case color(ThemeValue?) + case textColor + case backgroundColor + case other + + public var storedValue: Any? { + switch self { + case .keyPath(let value): return value + case .color(let value): return value + case .state(let value): return value + case .textColor, .backgroundColor, .other: return nil + } + } } private let applyTheme: @MainActor (Theme) -> () - private let info: [AnyHashable] + private let info: [Info] private var otherAppliers: [ThemeApplier]? + public var allInfo: [[Info]] { + return [info] + (otherAppliers?.flatMap { $0.allInfo } ?? []) + } + @MainActor init( existingApplier: ThemeApplier?, - info: [AnyHashable], + info: [Info], applyTheme: @escaping @MainActor (Theme) -> () ) { self.applyTheme = applyTheme @@ -536,16 +600,11 @@ internal class ThemeApplier { .appending(contentsOf: existingApplier?.otherAppliers) .compactMap { $0?.clearingOtherAppliers() } .filter { $0.info != info } - - // Automatically apply the theme immediately (if the database has been setup) - if SNUIKit.config?.isStorageValid == true || ThemeManager.syncState.hasLoadedTheme { - apply(theme: ThemeManager.syncState.state.theme, isInitialApplication: true) - } } // MARK: - Functions - public func removing(allWith info: AnyHashable) -> ThemeApplier? { + public func removing(allWith info: Info) -> ThemeApplier? { let remainingAppliers: [ThemeApplier] = [self] .appending(contentsOf: self.otherAppliers) .filter { applier in !applier.info.contains(info) } @@ -570,6 +629,13 @@ internal class ThemeApplier { return self } + @MainActor fileprivate func performInitialApplicationIfNeeded() { + // Only perform the initial application if both the database and theme have been setup + if SNUIKit.config?.isStorageValid == true || ThemeManager.syncState.hasLoadedTheme { + apply(theme: ThemeManager.syncState.state.theme, isInitialApplication: true) + } + } + @MainActor fileprivate func apply(theme: Theme, isInitialApplication: Bool = false) { self.applyTheme(theme) diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 42cad3d47e..7701c948f2 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -6,7 +6,12 @@ import UIKit public extension NSAttributedString.Key { internal static let themedKeys: Set = [ - .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, .themeStrikethroughColor + .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, + .themeStrikethroughColor + ] + + internal static let specialKeys: Set = [ + .themeAlphaMultiplier, .themeCurrentUserMentionImage ] static let themeForegroundColor = NSAttributedString.Key("org.getsession.themeForegroundColor") @@ -14,6 +19,8 @@ public extension NSAttributedString.Key { static let themeStrokeColor = NSAttributedString.Key("org.getsession.themeStrokeColor") static let themeUnderlineColor = NSAttributedString.Key("org.getsession.themeUnderlineColor") static let themeStrikethroughColor = NSAttributedString.Key("org.getsession.themeStrikethroughColor") + static let themeAlphaMultiplier = NSAttributedString.Key("org.getsession.themeAlphaMultiplier") + static let themeCurrentUserMentionImage = NSAttributedString.Key("org.getsession.themeCurrentUserMentionImage") internal var originalKey: NSAttributedString.Key? { switch self { @@ -42,6 +49,17 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha } public var string: String { attributedString.string } + public var allAttributes: [(attributes: [NSAttributedString.Key: Any], range: NSRange)] { + var result: [(attributes: [NSAttributedString.Key: Any], range: NSRange)] = [] + let attrString: NSAttributedString = attributedString + let fullRange: NSRange = NSRange(location: 0, length: attrString.length) + + attrString.enumerateAttributes(in: fullRange) { attributes, range, _ in + result.append((attributes, range)) + } + + return result + } /// It seems that a number of UI elements don't properly check the `NSTextAttachment.accessibilityLabel` when /// constructing their accessibility label, as such we need to construct our own which includes that content @@ -233,6 +251,13 @@ public final class ThemedAttributedString: @unchecked Sendable, Equatable, Hasha self._attributedString.replaceCharacters(in: range, with: attributedString) } + public func replacingCharacters(in range: NSRange, with attributedString: NSAttributedString) -> ThemedAttributedString { + lock.lock() + defer { lock.unlock() } + self._attributedString.replaceCharacters(in: range, with: attributedString) + return self + } + // MARK: - Convenience #if DEBUG diff --git a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift index cc84c5c137..e4db76b24e 100644 --- a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift @@ -7,7 +7,7 @@ public extension UIView { set { // First we should remove any gradient that had been added self.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() - ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) + ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue, as: .backgroundColor) } get { return nil } } @@ -78,7 +78,7 @@ public extension UIView { public extension UILabel { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -104,7 +104,7 @@ public extension UILabel { public extension UITextView { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -130,7 +130,7 @@ public extension UITextView { public extension UITextField { var themeTextColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.textColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.textColor, to: newValue, as: .textColor) } get { return nil } } @@ -158,26 +158,25 @@ public extension UIButton { func setThemeBackgroundColor(_ value: ThemeValue?, for state: UIControl.State) { let keyPath: KeyPath = \.imageView?.image - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [ - keyPath, - state.rawValue - ] - ) { [weak self] theme in - guard - let value: ThemeValue = value, - let color: UIColor = ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)) - else { - self?.setBackgroundImage(nil, for: state) - return - } - - self?.setBackgroundImage(color.toImage(), for: state) + info: [ + .keyPath(keyPath), + .backgroundColor, + .color(value), + .state(state) + ] + ) { [weak self] theme in + guard + let value: ThemeValue = value, + let color: UIColor = ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)) + else { + self?.setBackgroundImage(nil, for: state) + return } - ) + + self?.setBackgroundImage(color.toImage(), for: state) + } } func setThemeBackgroundColorForced(_ newValue: ForcedThemeValue?, for state: UIControl.State) { @@ -187,7 +186,7 @@ public extension UIButton { ThemeManager.set( self, to: ThemeManager.get(for: self)? - .removing(allWith: keyPath) + .removing(allWith: .keyPath(keyPath)) ) switch newValue { @@ -206,26 +205,25 @@ public extension UIButton { func setThemeTitleColor(_ value: ThemeValue?, for state: UIControl.State) { let keyPath: KeyPath = \.titleLabel?.textColor - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [ - keyPath, - state.rawValue - ] - ) { [weak self] theme in - guard let value: ThemeValue = value else { - self?.setTitleColor(nil, for: state) - return - } - - self?.setTitleColor( - ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)), - for: state - ) + info: [ + .keyPath(keyPath), + .textColor, + .color(value), + .state(state) + ] + ) { [weak self] theme in + guard let value: ThemeValue = value else { + self?.setTitleColor(nil, for: state) + return } - ) + + self?.setTitleColor( + ThemeManager.resolvedColor(ThemeManager.color(for: value, in: theme)), + for: state + ) + } } func setThemeTitleColorForced(_ newValue: ForcedThemeValue?, for state: UIControl.State) { @@ -235,7 +233,7 @@ public extension UIButton { ThemeManager.set( self, to: ThemeManager.get(for: self)? - .removing(allWith: keyPath) + .removing(allWith: .keyPath(keyPath)) ) switch newValue { @@ -359,30 +357,27 @@ public extension GradientView { // First we should clear out any dynamic setting ThemeManager.remove(self, keyPath: \.backgroundColor) - ThemeManager.set( + ThemeManager.storeAndApply( self, - to: ThemeApplier( - existingApplier: ThemeManager.get(for: self), - info: [keyPath] - ) { [weak self] theme in - // First we should remove any gradient that had been added - self?.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() - - let maybeColors: [CGColor]? = newValue?.compactMap { - ThemeManager.color(for: $0, in: theme).cgColor - } - - guard let colors: [CGColor] = maybeColors, colors.count == newValue?.count else { - self?.backgroundColor = nil - return - } - - let layer: CAGradientLayer = CAGradientLayer() - layer.frame = (self?.bounds ?? .zero) - layer.colors = colors - self?.layer.insertSublayer(layer, at: 0) + info: [.keyPath(keyPath)] + ) { [weak self] theme in + // First we should remove any gradient that had been added + self?.layer.sublayers?.first(where: { $0 is CAGradientLayer })?.removeFromSuperlayer() + + let maybeColors: [CGColor]? = newValue?.compactMap { + ThemeManager.color(for: $0, in: theme).cgColor } - ) + + guard let colors: [CGColor] = maybeColors, colors.count == newValue?.count else { + self?.backgroundColor = nil + return + } + + let layer: CAGradientLayer = CAGradientLayer() + layer.frame = (self?.bounds ?? .zero) + layer.colors = colors + self?.layer.insertSublayer(layer, at: 0) + } } get { return nil } } @@ -440,7 +435,7 @@ public extension CAShapeLayer { public extension CALayer { @MainActor var themeBackgroundColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue, as: .backgroundColor) } get { return nil } } @@ -476,7 +471,7 @@ public extension CALayer { public extension CATextLayer { @MainActor var themeForegroundColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.foregroundColor, to: newValue) } + set { ThemeManager.set(self, keyPath: \.foregroundColor, to: newValue, as: .textColor) } get { return nil } } diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index 72c6a78bac..51223a3b3f 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -114,19 +114,56 @@ final public class LocalizationHelper: CustomStringConvertible { public extension LocalizationHelper { func localizedDeformatted() -> String { - return ThemedAttributedString(stringWithHTMLTags: localized(), font: .systemFont(ofSize: 14)).string + return ThemedAttributedString( + stringWithHTMLTags: localized(), + font: .systemFont(ofSize: 14), + attributes: [:], + mentionColor: nil, + currentUserMentionImage: nil + ).string } - func localizedFormatted(baseFont: UIFont) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: localized(), font: baseFont) + func localizedFormatted( + baseFont: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: localized(), + font: baseFont, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func localizedFormatted(in view: FontAccessible) -> ThemedAttributedString { - return localizedFormatted(baseFont: (view.fontValue ?? .systemFont(ofSize: 14))) + func localizedFormatted( + in view: FontAccessible, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return localizedFormatted( + baseFont: (view.fontValue ?? .systemFont(ofSize: 14)), + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func localizedFormatted(_ font: UIFont = .systemFont(ofSize: 14)) -> ThemedAttributedString { - return localizedFormatted(baseFont: font) + func localizedFormatted( + _ font: UIFont = .systemFont(ofSize: 14), + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return localizedFormatted( + baseFont: font, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } } diff --git a/SessionUIKit/Utilities/Localization+Style.swift b/SessionUIKit/Utilities/Localization+Style.swift index 43dce7671e..5a95f788f9 100644 --- a/SessionUIKit/Utilities/Localization+Style.swift +++ b/SessionUIKit/Utilities/Localization+Style.swift @@ -23,6 +23,9 @@ public extension ThemedAttributedString { case warningTheme = "warn" case dangerTheme = "error" case disabledTheme = "disabled" + case faded = "faded" + case mention = "mention" + case userMention = "userMention" // MARK: - Functions @@ -37,7 +40,11 @@ public extension ThemedAttributedString { ).map { ($0, isCloseTag) } } - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + func format( + with font: UIFont, + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> [NSAttributedString.Key: Any] { /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize switch self { case .bold: return [ @@ -59,11 +66,38 @@ public extension ThemedAttributedString { case .warningTheme: return [.themeForegroundColor: ThemeValue.warning] case .dangerTheme: return [.themeForegroundColor: ThemeValue.danger] case .disabledTheme: return [.themeForegroundColor: ThemeValue.disabled] + case .faded: return [.themeAlphaMultiplier: Values.lowOpacity] + case .mention: + guard let mentionColor: ThemeValue = mentionColor else { return [:] } + + return [ + .font: UIFont( + descriptor: (font.fontDescriptor.withSymbolicTraits(.traitBold) ?? font.fontDescriptor), + size: 0 + ), + .themeForegroundColor: mentionColor + ] + + case .userMention: + guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return [:] } + + return [.themeCurrentUserMentionImage: currentUserMentionImage] } } } - convenience init(stringWithHTMLTags: String?, font: UIFont) { + convenience init( + stringWithHTMLTags: String?, + font: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) { + let standardAttributes: [NSAttributedString.Key: Any] = [.font: font].merging( + attributes, + uniquingKeysWith: { _, new in new } + ) + guard let targetString: String = stringWithHTMLTags, let expression: NSRegularExpression = try? NSRegularExpression( @@ -71,7 +105,7 @@ public extension ThemedAttributedString { options: [.caseInsensitive, .dotMatchesLineSeparators] ) else { - self.init(string: (stringWithHTMLTags ?? "")) + self.init(string: (stringWithHTMLTags ?? ""), attributes: standardAttributes) return } @@ -79,7 +113,10 @@ public extension ThemedAttributedString { /// /// **Note:** We use an `NSAttributedString` for retrieving string ranges because if we don't then emoji characters /// can cause odd behaviours with accessing ranges so this simplifies the logic - let attrString: ThemedAttributedString = ThemedAttributedString(string: targetString) + let attrString: ThemedAttributedString = ThemedAttributedString( + string: targetString, + attributes: standardAttributes + ) let stringLength: Int = targetString.utf16.count var partsAndTags: [(part: String, tags: [HTMLTag])] = [] var openTags: [HTMLTag: Int] = [:] @@ -129,7 +166,7 @@ public extension ThemedAttributedString { /// If we don't have a `lastMatch` value then we weren't able to get a single valid tag match so just stop here are return the `targetString` guard let finalMatch: NSTextCheckingResult = lastMatch else { - self.init(string: targetString) + self.init(string: targetString, attributes: standardAttributes) return } @@ -144,7 +181,19 @@ public extension ThemedAttributedString { /// Lastly we should construct the attributed string, applying the desired formatting self.init( attributedString: partsAndTags.reduce(into: ThemedAttributedString()) { result, next in - result.append(ThemedAttributedString(string: next.part, attributes: next.tags.format(with: font))) + let partAttributes: [NSAttributedString.Key: Any] = next.tags.format( + with: font, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) + + result.append( + ThemedAttributedString( + string: next.part, + attributes: standardAttributes + .merging(partAttributes, uniquingKeysWith: { _, new in new }) + ) + ) } ) } @@ -164,7 +213,11 @@ public extension ThemedAttributedString { } private extension Collection where Element == ThemedAttributedString.HTMLTag { - func format(with font: UIFont) -> [NSAttributedString.Key: Any] { + func format( + with font: UIFont, + mentionColor: ThemeValue?, + currentUserMentionImage: UIImage? + ) -> [NSAttributedString.Key: Any] { func fontWith(_ font: UIFont, traits: UIFontDescriptor.SymbolicTraits) -> UIFont { /// **Note:** Constructing a `UIFont` with a `size`of `0` will preserve the textSize return UIFont( @@ -193,6 +246,17 @@ private extension Collection where Element == ThemedAttributedString.HTMLTag { case .warningTheme: result[.themeForegroundColor] = ThemeValue.warning case .dangerTheme: result[.themeForegroundColor] = ThemeValue.danger case .disabledTheme: result[.themeForegroundColor] = ThemeValue.disabled + case .faded: result[.themeAlphaMultiplier] = Values.lowOpacity + case .mention: + guard let mentionColor: ThemeValue = mentionColor else { return } + + result[.font] = fontWith(font, traits: [.traitBold]) + result[.themeForegroundColor] = mentionColor + + case .userMention: + guard let currentUserMentionImage: UIImage = currentUserMentionImage else { return } + + result[.themeCurrentUserMentionImage] = currentUserMentionImage } } } @@ -222,12 +286,34 @@ extension UITextField: DirectFontAccessible {} extension UITextView: DirectFontAccessible {} public extension String { - func formatted(in view: FontAccessible) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: self, font: (view.fontValue ?? .systemFont(ofSize: 14))) + func formatted( + in view: FontAccessible, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: self, + font: (view.fontValue ?? .systemFont(ofSize: 14)), + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } - func formatted(baseFont: UIFont) -> ThemedAttributedString { - return ThemedAttributedString(stringWithHTMLTags: self, font: baseFont) + func formatted( + baseFont: UIFont, + attributes: [NSAttributedString.Key: Any] = [:], + mentionColor: ThemeValue? = nil, + currentUserMentionImage: UIImage? = nil + ) -> ThemedAttributedString { + return ThemedAttributedString( + stringWithHTMLTags: self, + font: baseFont, + attributes: attributes, + mentionColor: mentionColor, + currentUserMentionImage: currentUserMentionImage + ) } func formatted() -> ThemedAttributedString { @@ -235,7 +321,13 @@ public extension String { } func deformatted() -> String { - return ThemedAttributedString(stringWithHTMLTags: self, font: .systemFont(ofSize: 14)).string + return ThemedAttributedString( + stringWithHTMLTags: self, + font: .systemFont(ofSize: 14), + attributes: [:], + mentionColor: nil, + currentUserMentionImage: nil + ).string } } diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 3843fa7369..829aa57282 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -8,6 +8,7 @@ public typealias DisplayNameRetriever = (_ sessionId: String, _ inMessageBody: B public enum MentionUtilities { private static let currentUserCacheKey: String = "Mention.CurrentUser" // stringlint:ignore private static let pubkeyRegex: NSRegularExpression = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) + private static let mentionCharacterSet: CharacterSet = CharacterSet(["@"]) // stringlint:ignore private static let mentionFont: UIFont = .boldSystemFont(ofSize: Values.smallFontSize) private static let currentUserMentionImageSizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) @@ -25,7 +26,12 @@ public enum MentionUtilities { return Set(pubkeyRegex .matches(in: string, range: NSRange(string.startIndex..., in: string)) - .compactMap { match in Range(match.range, in: string).map { String(string[$0]) } }) + .compactMap { match in + Range(match.range, in: string).map { range in + /// Need to remove the leading `@` as this should just retrieve the pubkeys + String(string[range]).trimmingCharacters(in: mentionCharacterSet) + } + }) } @MainActor public static func generateCurrentUserMentionImage(textColor: ThemeValue) -> UIImage { @@ -56,7 +62,7 @@ public enum MentionUtilities { var workingString: String = string let hasRLIPrefix: Bool = workingString.hasPrefix("\u{2067}") let hasPDISuffix: Bool = workingString.hasSuffix("\u{2069}") - + if hasRLIPrefix { workingString = String(workingString.dropFirst()) } @@ -65,130 +71,150 @@ public enum MentionUtilities { workingString = String(workingString.dropLast()) } - var string: String = workingString - var lastMatchEnd: Int = 0 + var nsString: NSString = (workingString as NSString) + let fullRange = NSRange(location: 0, length: nsString.length) + + let resultString: NSMutableString = NSMutableString() var mentions: [(range: NSRange, profileId: String, isCurrentUser: Bool)] = [] + var lastSearchLocation: Int = 0 - while let match: NSTextCheckingResult = pubkeyRegex.firstMatch( - in: string, - options: .withoutAnchoringBounds, - range: NSRange(location: lastMatchEnd, length: string.utf16.count - lastMatchEnd) - ) { - guard let range: Range = Range(match.range, in: string) else { break } + pubkeyRegex.enumerateMatches(in: workingString, options: [], range: fullRange) { match, _, _ in + guard let match else { return } + + /// Append everything before this match + let rangeBefore: NSRange = NSRange( + location: lastSearchLocation, + length: (match.range.location - lastSearchLocation) + ) + resultString.append(nsString.substring(with: rangeBefore)) - let sessionId: String = String(string[range].dropFirst()) // Drop the @ + let sessionId: String = String(nsString.substring(with: match.range).dropFirst()) /// Drop the @ let isCurrentUser: Bool = currentUserSessionIds.contains(sessionId) - let maybeTargetString: String? = { - guard !isCurrentUser else { return "you".localized() } - guard let displayName: String = displayNameRetriever(sessionId, true) else { - lastMatchEnd = (match.range.location + match.range.length) - return nil - } - - return displayName - }() + let displayName: String - guard let targetString: String = maybeTargetString else { continue } + if isCurrentUser { + displayName = "you".localized() + } + else if let retrievedName: String = displayNameRetriever(sessionId, true) { + displayName = retrievedName + } else { + /// If we can't get a proper display name then we should just truncate the pubkey + displayName = sessionId.truncated() + } - string = string.replacingCharacters(in: range, with: "@\(targetString)") // stringlint:ignore - lastMatchEnd = (match.range.location + targetString.utf16.count) + /// Append the resolved mame + let replacement: String = "@\(displayName)" // stringlint:ignore + let startLocation: Int = resultString.length + resultString.append(replacement) + /// Record the mention mentions.append(( - // + 1 to include the @ - range: NSRange(location: match.range.location, length: targetString.utf16.count + 1), + range: NSRange(location: startLocation, length: (replacement as NSString).length), profileId: sessionId, isCurrentUser: isCurrentUser )) + + lastSearchLocation = (match.range.location + match.range.length) + } + + /// Append any remaining string + if lastSearchLocation < nsString.length { + let remainingRange = NSRange(location: lastSearchLocation, length: nsString.length - lastSearchLocation) + resultString.append(nsString.substring(with: remainingRange)) } /// Need to add the RTL isolate markers back if we had them + let finalStringRaw: String = (resultString as String) let finalString: String = (string.containsRTL ? - "\(LocalizationHelper.forceRTLLeading)\(string)\(LocalizationHelper.forceRTLTrailing)" : - string + "\(LocalizationHelper.forceRTLLeading)\(finalStringRaw)\(LocalizationHelper.forceRTLTrailing)" : + finalStringRaw ) return (finalString, mentions) } - public static func highlightMentionsNoAttributes( + // stringlint:ignore_contents + public static func taggingMentions( in string: String, + location: MentionLocation, currentUserSessionIds: Set, displayNameRetriever: DisplayNameRetriever ) -> String { - let (string, _) = getMentions( + let (mentionReplacedString, mentions) = getMentions( in: string, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever ) - return string + guard !mentions.isEmpty else { return mentionReplacedString } + + let result: NSMutableString = NSMutableString(string: mentionReplacedString) + + /// Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { + let mentionText: String = (result as NSString).substring(with: mention.range) + let tag: String = (mention.isCurrentUser && location == .incomingMessage ? + ThemedAttributedString.HTMLTag.userMention.rawValue : /// Only use for incoming + ThemedAttributedString.HTMLTag.mention.rawValue + ) + + result.replaceCharacters( + in: mention.range, + with: "<\(tag)>\(mentionText)" + ) + } + + return (result as String) } - public static func highlightMentions( - in string: String, - currentUserSessionIds: Set, - location: MentionLocation, + public static func mentionColor( textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - displayNameRetriever: DisplayNameRetriever, + location: MentionLocation + ) -> ThemeValue { + switch location { + case .incomingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .outgoingMessage: return .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .outgoingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .incomingQuote: return .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .quoteDraft, .styleFree: return .dynamicForInterfaceStyle(light: textColor, dark: textColor) + } + } + + public static func currentUserMentionImageString( + substring: String, currentUserMentionImage: UIImage? - ) -> ThemedAttributedString { - let (string, mentions) = getMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: displayNameRetriever - ) + ) -> NSAttributedString { + guard let currentUserMentionImage else { return NSAttributedString(string: substring) } - let result = ThemedAttributedString(string: string, attributes: attributes) + /// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors + let attachment: NSTextAttachment = NSTextAttachment() + attachment.accessibilityLabel = substring - // Iterate in reverse so index ranges remain valid while replacing - for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { - if mention.isCurrentUser && location == .incomingMessage, let currentUserMentionImage { - /// Set the `accessibilityLabel` to ensure it's still visible to accessibility inspectors - let attachment: NSTextAttachment = NSTextAttachment() - attachment.accessibilityLabel = (result.attributedString.string as NSString).substring(with: mention.range) - - let offsetY: CGFloat = (mentionFont.capHeight - currentUserMentionImage.size.height) / 2 - attachment.image = currentUserMentionImage - attachment.bounds = CGRect( - x: 0, - y: offsetY, - width: currentUserMentionImage.size.width, - height: currentUserMentionImage.size.height - ) - - let attachmentString = NSMutableAttributedString(attachment: attachment) - - // Replace the mention text with the image attachment - result.replaceCharacters(in: mention.range, with: attachmentString) + let offsetY: CGFloat = ((mentionFont.capHeight - currentUserMentionImage.size.height) / 2) + attachment.image = currentUserMentionImage + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: currentUserMentionImage.size.width, + height: currentUserMentionImage.size.height + ) - let insertIndex = mention.range.location + attachmentString.length - if insertIndex < result.attributedString.length { - result.addAttribute(.kern, value: (3 * currentUserMentionImageSizeDiff), range: NSRange(location: insertIndex, length: 1)) - } - continue - } - - result.addAttribute(.font, value: mentionFont, range: mention.range) + return NSMutableAttributedString(attachment: attachment) + } +} - var targetColor: ThemeValue = textColor - switch location { - case .incomingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .outgoingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .outgoingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .incomingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .quoteDraft, .styleFree: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) - } - - result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) - } - - return result +public extension MentionUtilities { + static func resolveMentions( + in string: String, + currentUserSessionIds: Set, + displayNameRetriever: DisplayNameRetriever + ) -> String { + return MentionUtilities.taggingMentions( + in: string, + location: .outgoingMessage, /// If we are replacing then we don't want to use the image + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ).deformatted() } } @@ -197,7 +223,7 @@ public extension String { currentUserSessionIds: Set, displayNameRetriever: DisplayNameRetriever ) -> String { - return MentionUtilities.highlightMentionsNoAttributes( + return MentionUtilities.resolveMentions( in: self, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: displayNameRetriever diff --git a/SessionUIKit/Utilities/Notifications+Utilities.swift b/SessionUIKit/Utilities/Notifications+Utilities.swift new file mode 100644 index 0000000000..2ad9173ffb --- /dev/null +++ b/SessionUIKit/Utilities/Notifications+Utilities.swift @@ -0,0 +1,38 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Lucide + +public enum NotificationsUI { + public static let mutePrefix: Lucide.Icon = Lucide.Icon.volumeX + public static let mentionPrefix: Lucide.Icon = Lucide.Icon.atSign +} + +public extension ThemedAttributedString { + func stylingNotificationPrefixesIfNeeded(fontSize: CGFloat) -> ThemedAttributedString { + if self.string.starts(with: NotificationsUI.mutePrefix.rawValue) { + return addingAttributes( + Lucide.attributes(for: .systemFont(ofSize: fontSize)), + range: NSRange(location: 0, length: NotificationsUI.mutePrefix.rawValue.count) + ) + } + else if self.string.starts(with: NotificationsUI.mentionPrefix.rawValue) { + let imageAttachment: NSTextAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")? + .withRenderingMode(.alwaysTemplate) + imageAttachment.bounds = CGRect( + x: 0, + y: -2, + width: fontSize, + height: fontSize + ) + + return self.replacingCharacters( + in: NSRange(location: 0, length: NotificationsUI.mutePrefix.rawValue.count), + with: NSAttributedString(attachment: imageAttachment) + ) + } + + return self + } +} diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index ca7cea95c0..e988982c7e 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -204,3 +204,15 @@ extension AnyPublisher: @retroactive ExpressibleByArrayLiteral where Output: Ran self = Just(Output(elements)).setFailureType(to: Failure.self).eraseToAnyPublisher() } } + +public extension AnyPublisher where Failure == Error { + static func lazy(_ closure: @escaping () throws -> Output) -> Self { + return Deferred { + Future { promise in + do { promise(.success(try closure())) } + catch { promise(.failure(error)) } + } + } + .eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Database/Types/PagedData.swift b/SessionUtilitiesKit/Database/Types/PagedData.swift index a5fbf78b67..88db0d6d33 100644 --- a/SessionUtilitiesKit/Database/Types/PagedData.swift +++ b/SessionUtilitiesKit/Database/Types/PagedData.swift @@ -88,6 +88,26 @@ public extension PagedData { self.info = info self.newIds = newIds } + + public static func createInvalid() -> LoadResult { + LoadResult( + info: PagedData.LoadedInfo( + queryInfo: PagedData.QueryInfo( + tableName: "", + idColumnName: "", + requiredJoinSQL: nil, + filterSQL: "", + groupSQL: nil, + orderSQL: "" + ), + pageSize: 0, + totalCount: 0, + firstPageOffset: 0, + currentIds: [] + ), + newIds: [] + ) + } } @available(*, deprecated, message: "This type was used with the PagedDatabaseObserver but that is deprecated, use the ObservationBuilder instead and PagedData.LoadedInfo") diff --git a/SessionUtilitiesKit/General/Authentication.swift b/SessionUtilitiesKit/General/Authentication.swift index 62b3662bde..f44a3a1d78 100644 --- a/SessionUtilitiesKit/General/Authentication.swift +++ b/SessionUtilitiesKit/General/Authentication.swift @@ -4,11 +4,20 @@ import Foundation import GRDB public enum Authentication {} -public protocol AuthenticationMethod: SignatureGenerator { +public protocol AuthenticationMethod: Sendable, SignatureGenerator { var info: Authentication.Info { get } } public extension AuthenticationMethod { + var isInvalid: Bool { + switch info { + case .standard(let sessionId, let ed25519PublicKey): + return (sessionId == .invalid || ed25519PublicKey.isEmpty) + + default: return false + } + } + var swarmPublicKey: String { get throws { switch info { @@ -21,6 +30,30 @@ public extension AuthenticationMethod { } } +public extension Authentication { + static let invalid: AuthenticationMethod = Invalid() + + struct Invalid: AuthenticationMethod { + public var info: Authentication.Info = .standard(sessionId: .invalid, ed25519PublicKey: []) + + public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { + throw CryptoError.invalidAuthentication + } + } +} + +public struct EquatableAuthenticationMethod: Sendable, Equatable { + public let value: AuthenticationMethod + + public init(value: AuthenticationMethod) { + self.value = value + } + + public static func ==(lhs: EquatableAuthenticationMethod, rhs: EquatableAuthenticationMethod) -> Bool { + return (lhs.value.info == rhs.value.info) + } +} + // MARK: - SignatureGenerator public protocol SignatureGenerator { @@ -54,7 +87,7 @@ public extension Authentication { // MARK: - Authentication.Info public extension Authentication { - enum Info: Equatable { + enum Info: Sendable, Equatable { /// Used when interacting as the current user case standard(sessionId: SessionId, ed25519PublicKey: [UInt8]) diff --git a/SessionUtilitiesKit/Observations/ObservationUtilities.swift b/SessionUtilitiesKit/Observations/ObservationUtilities.swift index de8067cee1..8abaa8f6b2 100644 --- a/SessionUtilitiesKit/Observations/ObservationUtilities.swift +++ b/SessionUtilitiesKit/Observations/ObservationUtilities.swift @@ -2,36 +2,80 @@ import Foundation -public enum EventDataRequirement { - case databaseQuery - case other - case bothDatabaseQueryAndOther +public struct EventHandlingStrategy: OptionSet, Hashable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let none: EventHandlingStrategy = [] + public static let databaseQuery: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 0) + public static let libSessionQuery: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 1) + public static let directCacheUpdate: EventHandlingStrategy = EventHandlingStrategy(rawValue: 1 << 2) } public struct EventChangeset { - public let databaseEvents: Set + public static let empty: EventChangeset = EventChangeset(eventsByKey: [:], eventsByStrategy: [:]) + private let eventsByKey: [GenericObservableKey: [ObservedEvent]] + private let eventsByStrategy: [EventHandlingStrategy: Set] fileprivate init( - databaseEvents: Set, - eventsByKey: [GenericObservableKey: [ObservedEvent]] + eventsByKey: [GenericObservableKey: [ObservedEvent]], + eventsByStrategy: [EventHandlingStrategy: Set] ) { - self.databaseEvents = databaseEvents self.eventsByKey = eventsByKey + self.eventsByStrategy = eventsByStrategy + } + + // MARK: - Generic Event Accessors + + public func events(matching strategy: EventHandlingStrategy) -> Set { + var result: Set = [] + + eventsByStrategy.forEach { key, events in + if key.contains(strategy) { + result.formUnion(events) + } + } + + return result + } + + public var databaseEvents: Set { + return events(matching: .databaseQuery) } - // MARK: - Accessors + public var libSessionEvents: Set { + return events(matching: .libSessionQuery) + } /// Checks if any event matches the generic key - public func contains(_ key: GenericObservableKey) -> Bool { - return eventsByKey[key] != nil + public func containsGeneric(_ key: GenericObservableKey) -> Bool { + return containsAnyGeneric(key) + } + + public func containsAnyGeneric(_ keys: GenericObservableKey...) -> Bool { + return !Set(eventsByKey.keys).isDisjoint(with: Set(keys)) } /// Returns the most recent value for a specific key, cast to T - public func latest(_ key: GenericObservableKey, as type: T.Type = T.self) -> T? { + public func latestGeneric(_ key: GenericObservableKey, as type: T.Type = T.self) -> T? { return eventsByKey[key]?.last?.value as? T /// The `last` event should be the newest } + /// Returns the most recent value for a specific key, cast to T that matches the condition + public func latestGeneric(_ key: GenericObservableKey, as type: T.Type = T.self, where condition: (T) -> Bool) -> T? { + return eventsByKey[key]? + .reversed() /// The `last` event should be the newest so iterate backwards + .first(where: { + guard let value: T = $0.value as? T else { return false } + + return condition(value) + }) as? T + } + /// Iterates over all events matching the key, casting them to T public func forEach( _ key: GenericObservableKey, @@ -57,6 +101,41 @@ public struct EventChangeset { } } } + + // MARK: - Explicit Event Accessors + + /// Checks if any event matches the generic key + public func contains(_ key: ObservableKey) -> Bool { + return containsAny(key) + } + + public func containsAny(_ keys: ObservableKey...) -> Bool { + return keys.contains { key in + eventsByKey[key.generic]?.first(where: { $0.key == key }) != nil + } + } + + /// Returns the most recent value for a specific key, cast to T + public func latest(_ key: ObservableKey, as type: T.Type = T.self) -> T? { + return eventsByKey[key.generic]? + .reversed() /// The `last` event should be the newest + .first(where: { $0.key == key })? + .value as? T + } + + /// Returns the most recent value for a specific key, cast to T that matches the condition + public func latest(_ key: ObservableKey, as type: T.Type = T.self, where condition: (T) -> Bool) -> T? { + return eventsByKey[key.generic]? + .reversed() /// The `last` event should be the newest so iterate backwards + .first(where: { + guard + $0.key == key, + let value: T = $0.value as? T + else { return false } + + return condition(value) + }) as? T + } } public extension Collection where Element == ObservedEvent { @@ -67,24 +146,22 @@ public extension Collection where Element == ObservedEvent { allEvents[event.key.generic, default: []].append(event) } - return EventChangeset(databaseEvents: [], eventsByKey: allEvents) + return EventChangeset(eventsByKey: allEvents, eventsByStrategy: [:]) } func split( - by classifier: (ObservedEvent) -> EventDataRequirement + by classifier: (ObservedEvent) -> EventHandlingStrategy ) -> EventChangeset { - var dbEvents: Set = [] var allEvents: [GenericObservableKey: [ObservedEvent]] = [:] + var eventsByStrategy: [EventHandlingStrategy: Set] = [:] for event in self { allEvents[event.key.generic, default: []].append(event) - switch classifier(event) { - case .databaseQuery, .bothDatabaseQueryAndOther: dbEvents.insert(event) - case .other: break - } + let strategy: EventHandlingStrategy = classifier(event) + eventsByStrategy[strategy, default: []].insert(event) } - return EventChangeset(databaseEvents: dbEvents, eventsByKey: allEvents) + return EventChangeset(eventsByKey: allEvents, eventsByStrategy: eventsByStrategy) } } From 69c242fae6060c31d0d40be46023170dfd45021e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 12 Dec 2025 16:18:03 +1100 Subject: [PATCH 33/66] Bunch of bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Removed test pro sub id • Fixed a few font sizing bugs in the conversation list and search results • Fixed an issue where the conversation settings wouldn't respond to some conversation update events • Fixed an issue where the conversation screen wouldn't respond to some conversation update events • Fixed an issue where a user that had their name set to their id wouldn't render a truncated id • Fixed an issue where a group admin couldn't "Clear All Messages" just on the current device • Fixed an issue where clearing messages in a conversation wouldn't remove the messages from the UI until you left and returned to the screen • Fixed an issue where the home screen wouldn't update it's ordering when a message was deleted • Fixed an issue where the home screen wouldn't update it's unread counts when all messages in a conversation were removed • Fixed an issue where conversation snippets on the home screen incorrectly showed formatting --- Session.xcodeproj/project.pbxproj | 4 + .../Session_CompileLibSession.xcscheme | 1 - Session/Conversations/ConversationVC.swift | 2 +- .../Conversations/ConversationViewModel.swift | 29 +- .../Settings/ThreadSettingsViewModel.swift | 342 ++++++++++-------- .../DeveloperSettingsProViewModel.swift | 1 - Session/Shared/FullConversationCell.swift | 29 +- .../Database/Models/Interaction.swift | 10 + .../Database/Models/Profile.swift | 12 +- ...ProcessPendingGroupMemberRemovalsJob.swift | 8 + .../MessageSender+Groups.swift | 14 + .../Types/ConversationDataCache.swift | 28 +- .../Types/ConversationDataHelper.swift | 100 +++-- .../Types/ConversationInfoViewModel.swift | 2 + .../Types/MessageViewModel.swift | 17 +- .../ObservableKey+SessionMessagingKit.swift | 11 +- .../Database/Types/FetchableTriple.swift | 19 + 17 files changed, 382 insertions(+), 247 deletions(-) create mode 100644 SessionUtilitiesKit/Database/Types/FetchableTriple.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0a2ade308e..aef5816ec5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -1147,6 +1147,7 @@ FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; FDD42F462EE7B12100771A4C /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FDD42F452EE7B12100771A4C /* Lucide */; }; FDD42F482EE8D8ED00771A4C /* Notifications+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */; }; + FDD42F4A2EEB790900771A4C /* FetchableTriple.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD42F492EEB790500771A4C /* FetchableTriple.swift */; }; FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */; }; FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; @@ -2519,6 +2520,7 @@ FDD383702AFDD0E1001367F2 /* BencodeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponse.swift; sourceTree = ""; }; FDD383722AFDD6D7001367F2 /* BencodeResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BencodeResponseSpec.swift; sourceTree = ""; }; FDD42F472EE8D8E600771A4C /* Notifications+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notifications+Utilities.swift"; sourceTree = ""; }; + FDD42F492EEB790500771A4C /* FetchableTriple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchableTriple.swift; sourceTree = ""; }; FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResult.swift; sourceTree = ""; }; FDDD554D2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _033_ScheduleAppUpdateCheckJob.swift; sourceTree = ""; }; FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; @@ -4473,6 +4475,7 @@ children = ( FD17D7BE27F51F8200122BE0 /* ColumnExpressible.swift */, FD99A3AF2EBD4EDB00E59F94 /* FetchablePair.swift */, + FDD42F492EEB790500771A4C /* FetchableTriple.swift */, FD17D7B727F51ECA00122BE0 /* Migration.swift */, FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */, FD7162DA281B6C440060647B /* TypedTableAlias.swift */, @@ -7111,6 +7114,7 @@ 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */, + FDD42F4A2EEB790900771A4C /* FetchableTriple.swift in Sources */, FDE755052C9BB4EE002A2623 /* BencodeDecoder.swift in Sources */, FD360EC72ECD38750050CAF4 /* OptionSet+Utilities.swift in Sources */, FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, diff --git a/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme index f6f423999e..34930aa4d8 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session_CompileLibSession.xcscheme @@ -52,7 +52,6 @@ buildConfiguration = "Debug_Compile_LibSession" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - enableAddressSanitizer = "YES" enableASanStackUseAfterReturn = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 48d72ea806..e86a6bd4a9 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -741,7 +741,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa emptyStateLabelContainer.isHidden = (state.viewState != .empty) // If this is the initial load then just do a full table refresh - guard state.viewState == .loaded && initialLoadComplete else { + guard state.viewState != .loading && initialLoadComplete else { if state.viewState == .loaded { sections = updatedSections tableView.reloadData() diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index d6b98e61b4..57bf49e2df 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -728,16 +728,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold dataCache: dataCache ) - orderedIds.enumerated().forEach { index, id in + itemCache = orderedIds.enumerated().reduce(into: [:]) { result, next in let optimisticMessageId: Int64? let interaction: Interaction let reactionInfo: [MessageViewModel.ReactionInfo]? let maybeUnresolvedQuotedInfo: MessageViewModel.MaybeUnresolvedQuotedInfo? /// Source the interaction data from the appropriate location - switch id { + switch next.element { case ..<0: /// If the `id` is less than `0` then it's an optimistic message - guard let data: OptimisticMessageData = optimisticallyInsertedMessages[id] else { return } + guard let data: OptimisticMessageData = optimisticallyInsertedMessages[next.element] else { + return + } optimisticMessageId = data.temporaryId interaction = data.interaction @@ -752,12 +754,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } default: - guard let targetInteraction: Interaction = dataCache.interaction(for: id) else { return } + guard let targetInteraction: Interaction = dataCache.interaction(for: next.element) else { + return + } optimisticMessageId = nil interaction = targetInteraction - let reactions: [Reaction] = dataCache.reactions(for: id) + let reactions: [Reaction] = dataCache.reactions(for: next.element) if !reactions.isEmpty { reactionInfo = reactions.map { reaction in @@ -778,7 +782,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold reactionInfo = nil } - maybeUnresolvedQuotedInfo = dataCache.quoteInfo(for: id).map { info in + maybeUnresolvedQuotedInfo = dataCache.quoteInfo(for: next.element).map { info in MessageViewModel.MaybeUnresolvedQuotedInfo( foundQuotedInteractionId: info.foundQuotedInteractionId, resolvedQuotedInteraction: info.foundQuotedInteractionId.map { @@ -788,7 +792,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } } - itemCache[id] = MessageViewModel( + result[next.element] = MessageViewModel( optimisticMessageId: optimisticMessageId, interaction: interaction, reactionInfo: reactionInfo, @@ -797,26 +801,26 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadInfo: threadInfo, dataCache: dataCache, previousInteraction: State.interaction( - at: index + 1, /// Order is inverted so `previousInteraction` is the next element + at: next.offset + 1, /// Order is inverted so `previousInteraction` is the next element orderedIds: orderedIds, optimisticMessages: optimisticallyInsertedMessages, dataCache: dataCache ), nextInteraction: State.interaction( - at: index - 1, /// Order is inverted so `nextInteraction` is the previous element + at: next.offset - 1, /// Order is inverted so `nextInteraction` is the previous element orderedIds: orderedIds, optimisticMessages: optimisticallyInsertedMessages, dataCache: dataCache ), isLast: ( /// Order is inverted so we need to check the start of the list - index == 0 && + next.offset == 0 && !loadResult.info.hasPrevPage ), isLastOutgoing: ( /// Order is inverted so we need to check the start of the list - id == orderedIds - .prefix(index + 1) /// Want to include the value for `index` in the result + next.element == orderedIds + .prefix(next.offset + 1) /// Want to include the value for `index` in the result .enumerated() .compactMap { prefixIndex, _ in State.interaction( @@ -1580,7 +1584,6 @@ public extension ConversationViewModel { threadId: String, using dependencies: Dependencies ) throws -> ConversationInfoViewModel { - let userSessionId: SessionId = dependencies[cache: .general].sessionId var dataCache: ConversationDataCache = ConversationDataCache( userSessionId: dependencies[cache: .general].sessionId, context: ConversationDataCache.Context( diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index d9848f5fa5..b263971fa5 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -212,7 +212,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let dataCache: ConversationDataCache = ConversationDataCache( userSessionId: dependencies[cache: .general].sessionId, context: ConversationDataCache.Context( - source: .conversationList, + source: .conversationSettings(threadId: threadInfo.id), requireFullRefresh: false, requireAuthMethodFetch: false, requiresMessageRequestCountUpdate: false, @@ -287,7 +287,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Update the context dataCache.withContext( - source: .conversationList, + source: .conversationSettings(threadId: threadInfo.id), requireFullRefresh: ( isInitialQuery || changes.containsAny( @@ -990,31 +990,29 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) : .attributedText( "blockDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), - confirmTitle: (threadViewModel.threadIsBlocked == true ? + confirmTitle: (state.threadInfo.isBlocked ? "blockUnblock".localized() : "block".localized() ), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in - let isBlocked: Bool = (threadViewModel.threadIsBlocked == true) - - self?.updateBlockedState( - from: isBlocked, - isBlocked: !isBlocked, - threadId: threadViewModel.threadId, - displayName: threadViewModel.displayName + onTap: { [weak viewModel] in + viewModel?.updateBlockedState( + from: state.threadInfo.isBlocked, + isBlocked: !state.threadInfo.isBlocked, + threadId: state.threadInfo.id, + displayName: threadDisplayName ) } ) ), - (threadViewModel.threadIsNoteToSelf != true ? nil : + (!state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .hideNoteToSelf, leadingAccessory: .icon(isThreadHidden ? .eye : .eyeOff), @@ -1037,12 +1035,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: isThreadHidden ? .alert_text : .danger, cancelStyle: .alert_text ), - onTap: { [dependencies] in + onTap: { [dependencies = viewModel.dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { try SessionThread.updateVisibility( db, - threadId: threadViewModel.threadId, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, isVisible: true, using: dependencies ) @@ -1050,8 +1049,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi try SessionThread.deleteOrLeave( db, type: .hideContactConversation, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1074,36 +1073,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmationInfo: ConfirmationModal.Info( title: "clearMessages".localized(), body: { - guard threadViewModel.threadIsNoteToSelf != true else { + guard !state.threadInfo.isNoteToSelf else { return .attributedText( "clearMessagesNoteToSelfDescriptionUpdated" .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } - switch threadVariant { + + switch state.threadInfo.variant { case .contact: return .attributedText( "clearMessagesChatDescriptionUpdated" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .legacyGroup: return .attributedText( "clearMessagesGroupDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .community: return .attributedText( "clearMessagesCommunityUpdated" - .put(key: "community_name", value: threadViewModel.displayName) + .put(key: "community_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) case .group: - if currentUserIsClosedGroupAdmin { + if state.threadInfo.groupInfo?.currentUserRole == .admin { return .radio( explanation: "clearMessagesGroupAdminDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont), warning: nil, options: [ @@ -1130,7 +1130,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } else { return .attributedText( "clearMessagesGroupDescriptionUpdated" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) } @@ -1140,8 +1140,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text, dismissOnConfirm: false, - onConfirm: { [weak self, threadVariant, dependencies] modal in - if threadVariant == .group && currentUserIsClosedGroupAdmin { + onConfirm: { [weak viewModel, dependencies = viewModel.dependencies] modal in + if state.threadInfo.variant == .group && state.threadInfo.groupInfo?.currentUserRole == .admin { /// Determine the selected action index let selectedIndex: Int = { switch modal.info.body { @@ -1156,26 +1156,29 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } }() - // Return if the selected option is `Clear on this device` - guard selectedIndex != 0 else { return } - self?.deleteAllMessagesBeforeNow() + // Don't update the group if the selected option is `Clear on this device` + if selectedIndex != 0 { + viewModel?.deleteAllMessagesBeforeNow(state: state) + } } + dependencies[singleton: .storage].writeAsync( updates: { db in try Interaction.markAllAsDeleted( db, - threadId: threadViewModel.id, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, options: [.local, .noArtifacts], using: dependencies ) - }, completion: { [weak self] result in + }, + completion: { [weak viewModel] result in switch result { case .failure(let error): Log.error("Failed to clear messages due to error: \(error)") DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel?.showToast( text: "deleteMessageFailed" .putNumber(0) .localized(), @@ -1187,7 +1190,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .success: DispatchQueue.main.async { modal.dismiss(animated: true) { - self?.showToast( + viewModel?.showToast( text: "deleteMessageDeleted" .putNumber(0) .localized(), @@ -1203,7 +1206,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadViewModel.threadVariant != .community ? nil : + (state.threadInfo.variant != .community ? nil : SessionCell.Info( id: .leaveCommunity, leadingAccessory: .icon(.logOut), @@ -1217,21 +1220,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "communityLeave".localized(), body: .attributedText( "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "leave".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteCommunityAndContent, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1240,42 +1243,54 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (!currentUserIsClosedGroupMember ? nil : + (state.threadInfo.groupInfo?.currentUserRole == nil ? nil : SessionCell.Info( id: .leaveGroup, - leadingAccessory: .icon(currentUserIsClosedGroupAdmin ? .trash2 : .logOut), - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), + leadingAccessory: .icon(state.threadInfo.groupInfo?.currentUserRole == .admin ? + .trash2 : + .logOut + ), + title: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "groupDelete".localized() : + "groupLeave".localized() + ), styling: SessionCell.StyleInfo(tintColor: .danger), accessibility: Accessibility( identifier: "Leave group", label: "Leave group" ), confirmationInfo: ConfirmationModal.Info( - title: currentUserIsClosedGroupAdmin ? "groupDelete".localized() : "groupLeave".localized(), - body: (currentUserIsClosedGroupAdmin ? + title: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "groupDelete".localized() : + "groupLeave".localized() + ), + body: (state.threadInfo.groupInfo?.currentUserRole == .admin ? .attributedText( "groupDeleteDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( "groupLeaveDescription" - .put(key: "group_name", value: threadViewModel.displayName) + .put(key: "group_name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), - confirmTitle: currentUserIsClosedGroupAdmin ? "delete".localized() : "leave".localized(), + confirmTitle: (state.threadInfo.groupInfo?.currentUserRole == .admin ? + "delete".localized() : + "leave".localized() + ), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .leaveGroupAsync, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1284,7 +1299,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + (state.threadInfo.variant != .contact || state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .deleteConversation, leadingAccessory: .icon(.trash2), @@ -1298,21 +1313,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "conversationsDelete".localized(), body: .attributedText( "deleteConversationDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ), confirmTitle: "delete".localized(), confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1321,7 +1336,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ), - (threadVariant != .contact || threadViewModel.threadIsNoteToSelf == true ? nil : + (state.threadInfo.variant != .contact || state.threadInfo.isNoteToSelf ? nil : SessionCell.Info( id: .deleteContact, leadingAccessory: .icon( @@ -1337,7 +1352,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi title: "contactDelete".localized(), body: .attributedText( "deleteContactDescription" - .put(key: "name", value: threadViewModel.displayName) + .put(key: "name", value: threadDisplayName) .localizedFormatted(baseFont: ConfirmationModal.explanationFont), scrollMode: .never ), @@ -1345,14 +1360,14 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self, dependencies] in - self?.dismissScreen(type: .popToRoot) { + onTap: { [weak viewModel, dependencies = viewModel.dependencies] in + viewModel?.dismissScreen(type: .popToRoot) { dependencies[singleton: .storage].writeAsync { db in try SessionThread.deleteOrLeave( db, type: .deleteContactConversationAndContact, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, using: dependencies ) } @@ -1362,7 +1377,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ), // FIXME: [GROUPS REBUILD] Need to build this properly in a future release - (!dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil : + (!viewModel.dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || state.threadInfo.variant != .group ? nil : SessionCell.Info( id: .debugDeleteAttachmentsBeforeNow, leadingAccessory: .icon( @@ -1381,7 +1396,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi confirmStyle: .danger, cancelStyle: .alert_text ), - onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() } + onTap: { [weak viewModel] in viewModel?.deleteAllAttachmentsBeforeNow(state: state) } ) ) ].compactMap { $0 } @@ -1398,42 +1413,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Functions - private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { + private func inviteUsersToCommunity(threadInfo: ConversationInfoViewModel) { guard - let name: String = threadViewModel.openGroupName, - let server: String = threadViewModel.openGroupServer, - let roomToken: String = threadViewModel.openGroupRoomToken, - let publicKey: String = threadViewModel.openGroupPublicKey, + let communityInfo: ConversationInfoViewModel.CommunityInfo = threadInfo.communityInfo, let communityUrl: String = LibSession.communityUrlFor( - server: threadViewModel.openGroupServer, - roomToken: threadViewModel.openGroupRoomToken, - publicKey: threadViewModel.openGroupPublicKey + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey ) else { return } - let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = LibSession.OpenGroupCapabilityInfo( - roomToken: roomToken, - server: server, - publicKey: publicKey, - capabilities: (threadViewModel.openGroupCapabilities ?? []) - ) - let currentUserSessionIds: Set = Set([ - dependencies[cache: .general].sessionId.hexString, - SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString, - SessionThread.getCurrentUserBlindedSessionId( - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - openGroupCapabilityInfo: openGroupCapabilityInfo, - using: dependencies - )?.hexString - ].compactMap { $0 }) let contact: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() @@ -1447,7 +1436,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SELECT \(contact.allColumns) FROM \(contact) LEFT JOIN \(groupMember) ON ( - \(groupMember[.groupId]) = \(threadId) AND + \(groupMember[.groupId]) = \(threadInfo.id) AND \(groupMember[.profileId]) = \(contact[.id]) ) WHERE ( @@ -1455,7 +1444,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi \(contact[.isApproved]) = TRUE AND \(contact[.didApproveMe]) = TRUE AND \(contact[.isBlocked]) = FALSE AND - \(contact[.id]) NOT IN \(currentUserSessionIds) + \(contact[.id]) NOT IN \(threadInfo.currentUserSessionIds) ) """), footerTitle: "membersInviteTitle".localized(), @@ -1486,7 +1475,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi try LinkPreview( url: communityUrl, variant: .openGroupInvitation, - title: name, + title: communityInfo.name, using: dependencies ) .upsert(db) @@ -1498,7 +1487,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let interaction: Interaction = try Interaction( threadId: thread.id, threadVariant: thread.variant, - authorId: threadViewModel.currentUserSessionId, + authorId: threadInfo.userSessionId.hexString, variant: .standardOutgoing, timestampMs: sentTimestampMs, expiresInSeconds: destinationDisappearingMessagesConfiguration?.expiresInSeconds(), @@ -1590,12 +1579,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) } - private func viewMembers() { + private func viewMembers(state: State) { self.transitionToScreen( ThreadSettingsViewModel.createMemberListViewController( - threadId: threadId, - transitionToConversation: { [weak self, dependencies] maybeThreadViewModel in - guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + threadId: state.threadInfo.id, + transitionToConversation: { [weak self, dependencies] maybeThreadInfo in + guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else { self?.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( @@ -1612,7 +1601,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self?.transitionToScreen( ConversationVC( - threadViewModel: threadViewModel, + threadInfo: threadInfo, focusedInteractionInfo: nil, using: dependencies ), @@ -1624,7 +1613,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) } - private func promoteAdmins(currentGroupName: String?) { + private func promoteAdmins(state: State) { guard dependencies[feature: .updatedGroupsAllowPromotions] else { return } let groupMember: TypedTableAlias = TypedTableAlias() @@ -1646,7 +1635,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Actually trigger the sending process MessageSender .promoteGroupMembers( - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), members: memberInfo, isResend: isResend, using: dependencies @@ -1654,7 +1643,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( - receiveCompletion: { [threadId, dependencies] result in + receiveCompletion: { [dependencies] result in switch result { case .finished: break case .failure: @@ -1663,7 +1652,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Flag the members as failed dependencies[singleton: .storage].writeAsync { db in try? GroupMember - .filter(GroupMember.Columns.groupId == threadId) + .filter(GroupMember.Columns.groupId == state.threadInfo.id) .filter(memberIds.contains(GroupMember.Columns.profileId)) .updateAllAndConfig( db, @@ -1675,7 +1664,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Show a toast that the promotions failed to send viewModel?.showToast( text: GroupPromoteMemberJob.failureMessage( - groupName: (currentGroupName ?? "groupUnknown".localized()), + groupName: (state.threadInfo.groupInfo?.name ?? "groupUnknown".localized()), memberIds: memberIds, profileInfo: memberInfo.reduce(into: [:]) { result, next in result[next.id] = next.profile @@ -1700,7 +1689,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SELECT \(groupMember.allColumns) FROM \(groupMember) WHERE ( - \(groupMember[.groupId]) == \(threadId) AND ( + \(groupMember[.groupId]) == \(state.threadInfo.id) AND ( \(groupMember[.role]) == \(GroupMember.Role.admin) OR ( \(groupMember[.role]) != \(GroupMember.Role.admin) AND @@ -1744,6 +1733,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateNickname( + state: State, current: String?, displayName: String ) -> ConfirmationModal.Info { @@ -1786,7 +1776,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi cancelEnabled: .bool(current?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false), hasCloseButton: true, dismissOnConfirm: false, - onConfirm: { [weak self, dependencies, threadId] modal in + onConfirm: { [weak self, dependencies] modal in guard let finalNickname: String = (self?.updatedName ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1804,7 +1794,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi updates: { db in try Profile.updateIfNeeded( db, - publicKey: threadId, + publicKey: state.threadInfo.id, nicknameUpdate: .set(to: finalNickname), profileUpdateTimestamp: nil, /// Not set for `nickname` currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread @@ -1818,13 +1808,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } ) }, - onCancel: { [dependencies, threadId] modal in + onCancel: { [dependencies] modal in /// Remove the nickname dependencies[singleton: .storage].writeAsync( updates: { db in try Profile.updateIfNeeded( db, - publicKey: threadId, + publicKey: state.threadInfo.id, nicknameUpdate: .set(to: nil), profileUpdateTimestamp: nil, /// Not set for `nickname` currentUserSessionIds: [currentUserSessionId.hexString], /// Contact thread @@ -1842,6 +1832,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateGroupNameAndDescription( + state: State, currentName: String, currentDescription: String?, isUpdatedGroup: Bool @@ -1901,7 +1892,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi }, cancelStyle: .danger, dismissOnConfirm: false, - onConfirm: { [weak self, dependencies, threadId] modal in + onConfirm: { [weak self, dependencies] modal in guard let finalName: String = (self?.updatedName ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1925,7 +1916,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// Update the group appropriately MessageSender .updateGroup( - groupSessionId: threadId, + groupSessionId: state.threadInfo.id, name: finalName, groupDescription: finalDescription, using: dependencies @@ -1939,7 +1930,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) } - private func updateGroupDisplayPicture(currentUrl: String?) { + private func updateGroupDisplayPicture(state: State, currentUrl: String?) { guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return } let iconName: String = "profile_placeholder" // stringlint:ignore @@ -2013,6 +2004,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .image(.some(let source), _, _, let style, _, _, _, _, _): // FIXME: Need to add Group Pro display pic CTA self?.updateGroupDisplayPicture( + state: state, displayPictureUpdate: .groupUploadImage( source: source, cropRect: style.cropRect @@ -2036,6 +2028,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi hasSetNewProfilePicture = false } else { self?.updateGroupDisplayPicture( + state: state, displayPictureUpdate: .groupRemove, onUploadComplete: { [weak modal] in Task { @MainActor in modal?.close() } @@ -2065,6 +2058,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } private func updateGroupDisplayPicture( + state: State, displayPictureUpdate: DisplayPictureManager.Update, onUploadComplete: @escaping () -> () ) { @@ -2073,7 +2067,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi default: break } - Task.detached(priority: .userInitiated) { [weak self, threadId, dependencies] in + Task.detached(priority: .userInitiated) { [weak self, dependencies] in var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate var indicator: ModalActivityIndicatorViewController? @@ -2142,7 +2136,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let existingDownloadUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in try? ClosedGroup - .filter(id: threadId) + .filter(id: state.threadInfo.id) .select(.displayPictureUrl) .asRequest(of: String.self) .fetchOne(db) @@ -2150,7 +2144,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi do { try await MessageSender.updateGroup( - groupSessionId: threadId, + groupSessionId: state.threadInfo.id, displayPictureUpdate: targetUpdate, using: dependencies ) @@ -2195,8 +2189,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } } - private func toggleConversationPinnedStatus(currentPinnedPriority: Int32) async { - let isCurrentlyPinned: Bool = (currentPinnedPriority > LibSession.visiblePriority) + private func toggleConversationPinnedStatus(threadInfo: ConversationInfoViewModel) async { + let isCurrentlyPinned: Bool = (threadInfo.pinnedPriority > LibSession.visiblePriority) let isSessionPro: Bool = await dependencies[singleton: .sessionProManager] .currentUserIsPro .first(defaultValue: false) @@ -2204,7 +2198,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi if !isCurrentlyPinned && !isSessionPro { // TODO: [Database Relocation] Retrieve the full conversation list from lib session and check the pinnedPriority that way instead of using the database do { - let numPinnedConversations: Int = try await dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + let numPinnedConversations: Int = try await dependencies[singleton: .storage].writeAsync { [dependencies] db in let numPinnedConversations: Int = try SessionThread .filter(SessionThread.Columns.pinnedPriority > LibSession.visiblePriority) .fetchCount(db) @@ -2216,9 +2210,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// We have the space to pin the conversation, so do so try SessionThread.updateVisibility( db, - threadId: threadId, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, isVisible: true, - customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + customPriority: (threadInfo.pinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), using: dependencies ) @@ -2254,37 +2249,38 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } // If we are unpinning then no need to check the current count, just unpin immediately - try? await dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try SessionThread.updateVisibility( db, - threadId: threadId, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, isVisible: true, - customPriority: (currentPinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + customPriority: (threadInfo.pinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), using: dependencies ) } } - private func deleteAllMessagesBeforeNow() { - guard threadVariant == .group else { return } + private func deleteAllMessagesBeforeNow(state: State) { + guard state.threadInfo.variant == .group else { return } - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + dependencies[singleton: .storage].writeAsync { [dependencies] db in try LibSession.deleteMessagesBefore( db, - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), using: dependencies ) } } - private func deleteAllAttachmentsBeforeNow() { - guard threadVariant == .group else { return } + private func deleteAllAttachmentsBeforeNow(state: State) { + guard state.threadInfo.variant == .group else { return } - dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in + dependencies[singleton: .storage].writeAsync { [dependencies] db in try LibSession.deleteAttachmentsBefore( db, - groupSessionId: SessionId(.group, hex: threadId), + groupSessionId: SessionId(.group, hex: state.threadInfo.id), timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), using: dependencies ) @@ -2293,38 +2289,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Confirmation Modals - private func updateDisplayNameModal( - threadViewModel: SessionThreadViewModel, - currentUserIsClosedGroupAdmin: Bool - ) -> ConfirmationModal.Info? { - guard !threadViewModel.threadIsNoteToSelf else { return nil } + private func updateDisplayNameModal(state: State) -> ConfirmationModal.Info? { + guard !state.threadInfo.isNoteToSelf else { return nil } - switch (threadViewModel.threadVariant, currentUserIsClosedGroupAdmin) { + switch (state.threadInfo.variant, state.threadInfo.groupInfo?.currentUserRole) { case (.contact, _): return self.updateNickname( - current: threadViewModel.profile?.nickname, + state: state, + current: state.threadInfo.profile?.nickname, displayName: ( /// **Note:** We want to use the `profile` directly rather than `threadViewModel.displayName` /// as the latter would use the `nickname` here which is incorrect - threadViewModel.profile?.displayName(ignoreNickname: true) ?? - threadViewModel.threadId.truncated() + state.threadInfo.profile?.displayName(ignoreNickname: true) ?? + state.threadInfo.displayName.deformatted() ) ) - case (.group, true), (.legacyGroup, true): + case (.group, .admin), (.legacyGroup, .admin): return self.updateGroupNameAndDescription( - currentName: threadViewModel.displayName, - currentDescription: threadViewModel.threadDescription, - isUpdatedGroup: (threadViewModel.threadVariant == .group) + state: state, + currentName: state.threadInfo.displayName.deformatted(), + currentDescription: state.threadInfo.conversationDescription, + isUpdatedGroup: (state.threadInfo.variant == .group) ) - case (.community, _), (.legacyGroup, false), (.group, false): return nil + case (.community, _), (.legacyGroup, _), (.group, _): return nil } } - private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { + private func showQRCodeLightBox(for threadInfo: ConversationInfoViewModel) { let qrCodeImage: UIImage = QRCode.generate( - for: threadViewModel.getQRCodeString(), + for: threadInfo.qrCodeString, hasBackground: false, iconName: "SessionWhite40" // stringlint:ignore ) @@ -2363,3 +2358,40 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.transitionToScreen(viewController, transitionType: .present) } } + +// MARK: - Convenience + +private extension ObservedEvent { + var handlingStrategy: EventHandlingStrategy { + let threadInfoStrategy: EventHandlingStrategy? = ConversationInfoViewModel.handlingStrategy(for: self) + let localStrategy: EventHandlingStrategy = { + switch (key, key.generic) { + case (.appLifecycle(.willEnterForeground), _): return .databaseQuery + case (.databaseLifecycle(.resumed), _): return .databaseQuery + + default: return .directCacheUpdate + } + }() + + return localStrategy.union(threadInfoStrategy ?? .none) + } +} + +private extension ConversationInfoViewModel { + var qrCodeString: String { + switch self.variant { + case .contact, .legacyGroup, .group: return id + case .community: + guard + let communityInfo: CommunityInfo = self.communityInfo, + let urlString: String = LibSession.communityUrlFor( + server: communityInfo.server, + roomToken: communityInfo.roomToken, + publicKey: communityInfo.publicKey + ) + else { return "" } + + return urlString + } + } +} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 7b1a553ccb..d098a2445c 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -864,7 +864,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold private func purchaseSubscription(currentProduct: Product?) async { do { let products: [Product] = try await Product.products(for: [ - "com.getsession.org.pro_sub", "com.getsession.org.pro_sub_1_month", "com.getsession.org.pro_sub_3_months", "com.getsession.org.pro_sub_12_months" diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index b9ef894b26..42037cc300 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -10,6 +10,7 @@ import SessionUtilitiesKit public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticCell { public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 + private static let displayNameFont: UIFont = .boldSystemFont(ofSize: Values.mediumFontSize) private static let snippetFont: UIFont = .systemFont(ofSize: Values.smallFontSize) // MARK: - UI @@ -32,7 +33,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC proBadgeSize: .small, withStretchingSpacer: false ) - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.font = FullConversationCell.displayNameFont result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail result.isProBadgeHidden = true @@ -282,6 +283,14 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - Content + public override func prepareForReuse() { + super.prepareForReuse() + + /// Need to reset the fonts as it seems that the `.font` values can end up using a styled font from the attributed text + displayNameLabel.font = FullConversationCell.displayNameFont + snippetLabel.font = FullConversationCell.snippetFont + } + // MARK: --Search Results public func updateForDefaultContacts(with cellViewModel: ConversationInfoViewModel, using dependencies: Dependencies) { profilePictureView.setDataManager(dependencies[singleton: .imageDataManager]) @@ -432,7 +441,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC typingIndicatorView.stopAnimation() snippetLabel.themeAttributedText = cellViewModel.lastInteraction?.messageSnippet? .formatted(baseFont: snippetLabel.font) - .stylingNotificationPrefixesIfNeeded(fontSize: Values.smallFontSize) + .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } let stateInfo = cellViewModel.lastInteraction?.state.statusIconInfo( @@ -465,19 +474,19 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC switch (isMuted, hasMutePrefix) { case (true, false): - self.snippetLabel.themeAttributedText = ThemedAttributedString( + snippetLabel.themeAttributedText = ThemedAttributedString( string: NotificationsUI.mutePrefix.rawValue, - attributes: Lucide.attributes(for: .systemFont(ofSize: Values.smallFontSize)) + attributes: Lucide.attributes(for: .systemFont(ofSize: Values.verySmallFontSize)) ) - .appending(attrString) + .appending(NSAttributedString(string: " ")) + .appending(attrString.adding(attributes: [.font: FullConversationCell.snippetFont])) case (false, true): - self.snippetLabel.attributedText = attrString + /// Need to remove the space as well + let location: Int = (NotificationsUI.mutePrefix.rawValue.count + 1) + snippetLabel.attributedText = attrString .attributedSubstring( - from: NSRange( - location: NotificationsUI.mutePrefix.rawValue.count, - length: (attrString.length - NotificationsUI.mutePrefix.rawValue.count) - ) + from: NSRange(location: location, length: (attrString.length - location)) ) default: break diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index a66b9628e5..c6cf19ed73 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -1453,10 +1453,20 @@ public extension Interaction { } /// Delete the reactions from the database + let reactionInfo: Set> = try Reaction + .select(Column.rowID, Reaction.Columns.interactionId, Reaction.Columns.emoji) + .filter(interactionIds.contains(Reaction.Columns.interactionId)) + .asRequest(of: FetchableTriple.self) + .fetchSet(db) _ = try Reaction .filter(interactionIds.contains(Reaction.Columns.interactionId)) .deleteAll(db) + /// Notify about the reaction deletion + reactionInfo.forEach { info in + db.addReactionEvent(id: info.first, messageId: info.second, change: .removed(info.third)) + } + /// Flag the `SnodeReceivedMessageInfo` records as invalid (otherwise we might try to poll for a hash which no longer /// exists, resulting in fetching the last 14 days of messages) let serverHashes: Set = interactionInfo.compactMap(\.serverHash).asSet() diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index f5f58077f3..f3ae345614 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -303,14 +303,14 @@ public extension Profile { // stringlint:ignore_contents switch (nickname, name, customFallback, includeSessionIdSuffix) { - case (.some(let value), _, _, false) where !value.isEmpty, - (_, .some(let value), _, false) where !value.isEmpty, - (_, _, .some(let value), false) where !value.isEmpty: + case (.some(let value), _, _, false) where !value.isEmpty && value != id, + (_, .some(let value), _, false) where !value.isEmpty && value != id, + (_, _, .some(let value), false) where !value.isEmpty && value != id: return value - case (.some(let value), _, _, true) where !value.isEmpty, - (_, .some(let value), _, true) where !value.isEmpty, - (_, _, .some(let value), true) where !value.isEmpty: + case (.some(let value), _, _, true) where !value.isEmpty && value != id, + (_, .some(let value), _, true) where !value.isEmpty && value != id, + (_, _, .some(let value), true) where !value.isEmpty && value != id: return (Dependencies.isRTL ? "(\(id.truncated(prefix: 4, suffix: 4))) \(value)" : "​\(value) (\(id.truncated(prefix: 4, suffix: 4)))" diff --git a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift index 6bde4e98ef..0527456be6 100644 --- a/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift +++ b/SessionMessagingKit/Jobs/ProcessPendingGroupMemberRemovalsJob.swift @@ -235,6 +235,14 @@ public enum ProcessPendingGroupMemberRemovalsJob: JobExecutor { .filter(pendingRemovals.keys.contains(GroupMember.Columns.profileId)) .deleteAll(db) + pendingRemovals.keys.forEach { id in + db.addGroupMemberEvent( + profileId: id, + threadId: groupSessionId.hexString, + type: .deleted + ) + } + /// If we want to remove the messages sent by the removed members then do so and remove /// them from the swarm as well if !memberIdsToRemoveContent.isEmpty { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 8e1a77adf7..6726d4b77f 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -631,6 +631,12 @@ extension MessageSender { roleStatus: .sending, isHidden: false ).upsert(db) + + db.addGroupMemberEvent( + profileId: id, + threadId: sessionId.hexString, + type: .created + ) } } } @@ -1142,6 +1148,14 @@ extension MessageSender { GroupMember.Columns.roleStatus.set(to: GroupMember.RoleStatus.sending), using: dependencies ) + + memberIds.forEach { id in + db.addGroupMemberEvent( + profileId: id, + threadId: groupSessionId.hexString, + type: .updated(.role(role: .admin, status: .sending)) + ) + } } } diff --git a/SessionMessagingKit/Types/ConversationDataCache.swift b/SessionMessagingKit/Types/ConversationDataCache.swift index 636813cadb..b7a8cd5ae2 100644 --- a/SessionMessagingKit/Types/ConversationDataCache.swift +++ b/SessionMessagingKit/Types/ConversationDataCache.swift @@ -51,9 +51,6 @@ public struct ConversationDataCache: Sendable, Equatable, Hashable { /// Stores `threadId -> interactionStats` public fileprivate(set) var interactionStats: [String: ConversationInfoViewModel.InteractionStats] = [:] - /// Stores `threadId -> InteractionInfo` (the last interaction info for the thread) - public fileprivate(set) var lastInteractions: [String: ConversationInfoViewModel.InteractionInfo] = [:] - /// Stores `threadId -> currentUserSessionIds` public fileprivate(set) var currentUserSessionIds: [String: Set] = [:] @@ -114,9 +111,6 @@ public extension ConversationDataCache { func interactionStats(for threadId: String) -> ConversationInfoViewModel.InteractionStats? { interactionStats[threadId] } - func lastInteraction(for threadId: String) -> ConversationInfoViewModel.InteractionInfo? { - lastInteractions[threadId] - } func currentUserSessionIds(for threadId: String) -> Set { return (currentUserSessionIds[threadId] ?? [userSessionId.hexString]) } @@ -259,14 +253,6 @@ public extension ConversationDataCache { interactionStats.forEach { self.interactionStats[$0.threadId] = $0 } } - mutating func insert(_ lastInteraction: ConversationInfoViewModel.InteractionInfo) { - self.lastInteractions[lastInteraction.threadId] = lastInteraction - } - - mutating func insert(lastInteractions: [String: ConversationInfoViewModel.InteractionInfo]) { - self.lastInteractions.merge(lastInteractions) { _, new in new } - } - mutating func setCurrentUserSessionIds(_ currentUserSessionIds: [String: Set]) { self.currentUserSessionIds = currentUserSessionIds } @@ -353,7 +339,6 @@ public extension ConversationDataCache { self.disappearingMessagesConfigurations.removeValue(forKey: threadId) self.interactionStats.removeValue(forKey: threadId) self.incomingTyping.remove(threadId) - self.lastInteractions.removeValue(forKey: threadId) let interactions: [Interaction] = Array(self.interactions.values) interactions.forEach { interaction in @@ -380,6 +365,19 @@ public extension ConversationDataCache { } } + mutating func remove(interactionStatsForThreadIds: Set) { + interactionStatsForThreadIds.forEach { id in + guard let stats: ConversationInfoViewModel.InteractionStats = self.interactionStats[id] else { + return + } + + self.interactionStats.removeValue(forKey: id) + self.interactions.removeValue(forKey: stats.latestInteractionId) + self.attachmentMap[stats.latestInteractionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) } + self.attachmentMap.removeValue(forKey: stats.latestInteractionId) + } + } + mutating func removeAttachmentMap(for interactionId: Int64) { self.attachmentMap.removeValue(forKey: interactionId) } diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 1909ddf481..62656eb71f 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -12,6 +12,7 @@ public extension ConversationDataCache { public enum Source: Sendable, Equatable, Hashable { case conversationList case messageList(threadId: String) + case conversationSettings(threadId: String) case searchResults } @@ -22,20 +23,6 @@ public extension ConversationDataCache { let requiresInitialUnreadInteractionInfo: Bool let requireRecentReactionEmojiUpdate: Bool - var isConversationList: Bool { - switch source { - case .conversationList: return true - default: return false - } - } - - var isMessageList: Bool { - switch source { - case .messageList: return true - default: return false - } - } - // MARK: - Initialization public init( @@ -58,7 +45,7 @@ public extension ConversationDataCache { func insertedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { switch source { - case .searchResults: return [] + case .searchResults, .conversationSettings: return [] case .conversationList: return (requirements.insertedThreadIds as? Set ?? []) case .messageList: return (requirements.insertedInteractionIds as? Set ?? []) } @@ -66,7 +53,7 @@ public extension ConversationDataCache { func deletedItemIds(_ requirements: ConversationDataHelper.FetchRequirements, as: ID.Type) -> Set { switch source { - case .searchResults: return [] + case .searchResults, .conversationSettings: return [] case .conversationList: return (requirements.deletedThreadIds as? Set ?? []) case .messageList: return (requirements.deletedInteractionIds as? Set ?? []) } @@ -94,8 +81,8 @@ public extension ConversationDataHelper { /// Validate we have the bear minimum data for the source switch currentCache.context.source { case .conversationList, .searchResults: break - case .messageList(let threadId): - /// On the message list if we don't currently have the thread cached then we need to fetch it + case .messageList(let threadId), .conversationSettings(let threadId): + /// On the message list and conversation settings if we don't currently have the thread cached then we need to fetch it guard currentCache.thread(for: threadId) == nil else { break } requirements.threadIdsNeedingFetch.insert(threadId) @@ -111,8 +98,12 @@ public extension ConversationDataHelper { case .conversationList: requirements.threadIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) - case .messageList: + case .messageList(let threadId): + requirements.threadIdsNeedingFetch.insert(threadId) requirements.interactionIdsNeedingFetch.insert(contentsOf: Set(itemCache.keys) as? Set) + + case .conversationSettings(let threadId): + requirements.threadIdsNeedingFetch.insert(threadId) } } @@ -180,27 +171,49 @@ public extension ConversationDataHelper { /// Handle page loading events based on view context requirements.needsPageLoad = { - guard !currentCache.context.requireFullRefresh else { - return true /// Need to refetch the paged data in case the sorting changed + /// If we need a full refresh then we also need to refetch the paged data in case the sorting changed + if currentCache.context.requireFullRefresh { + return true } + /// If we had an event that directly impacted the paged data then we need a page load let hasDirectPagedDataChange: Bool = ( loadPageEvent != nil || !currentCache.context.insertedItemIds(requirements, as: Item.ID.self).isEmpty || !currentCache.context.deletedItemIds(requirements, as: Item.ID.self).isEmpty ) - guard !hasDirectPagedDataChange else { return true } + if hasDirectPagedDataChange { + return true + } switch currentCache.context.source { - case .messageList, .searchResults: return false + case .messageList, .searchResults, .conversationSettings: return false case .conversationList: /// On the conversation list if a new message is created in any conversation then we need to reload the paged /// data as it means the conversation order likely changed - guard changes.contains(.anyMessageCreatedInAnyConversation) else { return false } + if changes.contains(.anyMessageCreatedInAnyConversation) { + return true + } - return true + /// On the conversation list if the last message was deleted then we need to reload the paged data as it means + /// the conversation order likely changed + for key in itemCache.keys { + guard + let threadId: String = key as? String, + let stats: ConversationInfoViewModel.InteractionStats = currentCache.interactionStats( + for: threadId + ), + changes.contains(.messageDeleted(id: stats.latestInteractionId, threadId: threadId)) + else { continue } + + return true + } + + break } + + return false }() return requirements @@ -484,7 +497,7 @@ public extension ConversationDataHelper { switch (loadPageEvent?.target(with: loadResult), currentCache.context.source) { case (.some(let explicitTarget), _): target = explicitTarget - case (.none, .searchResults): target = .newItems(insertedIds: [], deletedIds: []) + case (.none, .searchResults), (.none, .conversationSettings): target = .newItems(insertedIds: [], deletedIds: []) case (.none, .conversationList): target = .reloadCurrent( insertedIds: currentCache.context.insertedItemIds(updatedRequirements, as: ID.self), @@ -503,7 +516,7 @@ public extension ConversationDataHelper { } switch currentCache.context.source { - case .searchResults: break + case .searchResults, .conversationSettings: break case .conversationList: if let newIds: [String] = updatedLoadResult.newIds as? [String], !newIds.isEmpty { updatedRequirements.threadIdsNeedingFetch.insert(contentsOf: Set(newIds)) @@ -572,10 +585,15 @@ public extension ConversationDataHelper { } if !updatedRequirements.threadIdsNeedingInteractionStats.isEmpty { + /// If we can't get the stats then it means the conversation has no more interactions which means we need to clear + /// out any old stats for that conversation (otherwise it'll show the wrong unread count) let stats: [ConversationInfoViewModel.InteractionStats] = try ConversationInfoViewModel.InteractionStats .request(for: updatedRequirements.threadIdsNeedingInteractionStats) .fetchAll(db) updatedCache.insert(interactionStats: stats) + updatedCache.remove(interactionStatsForThreadIds: updatedRequirements.threadIdsNeedingInteractionStats + .subtracting(Set(stats.map { $0.threadId }))) + updatedRequirements.interactionIdsNeedingFetch.insert( contentsOf: Set(stats.map { $0.latestInteractionId }) ) @@ -588,7 +606,7 @@ public extension ConversationDataHelper { /// **Note:** We may not be able to find the quoted interaction (hence the `Int64?` but would still want to render /// the message as a quote) switch currentCache.context.source { - case .conversationList, .searchResults: break + case .conversationList, .conversationSettings, .searchResults: break case .messageList(let threadId): let quoteInteractionIdResults: Set> = try MessageViewModel .quotedInteractionIds( @@ -635,6 +653,7 @@ public extension ConversationDataHelper { switch currentCache.context.source { case .conversationList, .searchResults: return (interactionAttachment.albumIndex == 0) case .messageList: return true + case .conversationSettings: return false } } .map { $0.attachmentId }) @@ -768,15 +787,15 @@ public extension ConversationDataHelper { updatedCache.insert(profiles: profiles) updatedRequirements.profileIdsNeedingFetch.removeAll() - /// If the source is `messageList` and we have blinded ids then we want to update the `unblindedIdMap` so - /// that we can show a users unblinded profile in the profile modal if possible + /// If the source is `messageList` or `conversationSettings` and we have blinded ids then we want to + /// update the `unblindedIdMap` so that we can show a users unblinded profile information if possible let blindedIds: Set = Set(profiles.map { $0.id } .filter { SessionId.Prefix.isCommunityBlinded($0) }) if !blindedIds.isEmpty { switch currentCache.context.source { case .conversationList, .searchResults: break - case .messageList: + case .messageList, .conversationSettings: let blindedIdMap: [String: String] = try BlindedIdLookup .filter(ids: blindedIds) .filter(BlindedIdLookup.Columns.sessionId != nil) @@ -857,13 +876,11 @@ private extension ConversationDataHelper { ) { guard let conversationEvent: ConversationEvent = event.value as? ConversationEvent else { return } - switch (event.key.generic, conversationEvent.change) { - case (.conversationCreated, _): requirements.insertedThreadIds.insert(conversationEvent.id) - case (.conversationDeleted, _): requirements.deletedThreadIds.insert(conversationEvent.id) - - case (_, .disappearingMessageConfiguration): - guard cache.context.isMessageList else { return } + switch (event.key.generic, conversationEvent.change, cache.context.source) { + case (.conversationCreated, _, _): requirements.insertedThreadIds.insert(conversationEvent.id) + case (.conversationDeleted, _, _): requirements.deletedThreadIds.insert(conversationEvent.id) + case (_, .disappearingMessageConfiguration, .messageList): /// Since we cache whether a messages disappearing message config can be followed we /// need to update the value if the disappearing message config on the conversation changes itemCache.forEach { _, item in @@ -893,6 +910,7 @@ private extension ConversationDataHelper { case .messageCreated: requirements.insertedInteractionIds.insert(interactionId) case .messageUpdated: requirements.interactionIdsNeedingFetch.insert(interactionId) case .messageDeleted: requirements.deletedInteractionIds.insert(interactionId) + case GenericObservableKey(.anyMessageCreatedInAnyConversation): requirements.insertedInteractionIds.insert(interactionId) @@ -905,9 +923,11 @@ private extension ConversationDataHelper { default: break } - if cache.context.isConversationList { - /// Any message event means we need to refetch interaction stats and latest message - requirements.threadIdsNeedingInteractionStats.insert(messageEvent.threadId) + switch cache.context.source { + case .messageList, .conversationSettings, .searchResults: break + case .conversationList: + /// Any message event means we need to refetch interaction stats and latest message + requirements.threadIdsNeedingInteractionStats.insert(messageEvent.threadId) } } } diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index accdccadfc..dd63519ee2 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -414,6 +414,8 @@ extension ConversationInfoViewModel: ObservableKeyProvider { if self.groupInfo != nil { result.insert(.groupInfo(groupId: id)) + result.insert(.groupMemberCreated(threadId: id)) + result.insert(.anyGroupMemberDeleted(threadId: id)) } if self.communityInfo != nil { diff --git a/SessionMessagingKit/Types/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift index 042c82bd87..e4c0897d67 100644 --- a/SessionMessagingKit/Types/MessageViewModel.swift +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -1085,9 +1085,18 @@ internal extension Interaction { groupSourceTypes.contains(dataCache.context.source) && groupThreadTypes.contains(threadVariant) ) + let shouldHaveStatusIcon: Bool = { + guard !isSearchResult && !groupKicked && !groupDestroyed else { return false } + + /// Only the standard conversation list should have a status icon prefix + switch dataCache.context.source { + case .messageList, .conversationSettings, .searchResults: return false + case .conversationList: return true + } + }() - /// Add status icon prefixes (these are only needed in the conversation list) - if dataCache.context.isConversationList && !isSearchResult && !groupKicked && !groupDestroyed { + /// Add status icon prefixes + if shouldHaveStatusIcon { if let thread = dataCache.thread(for: interaction.threadId) { let now: TimeInterval = dateNow.timeIntervalSince1970 let mutedUntil: TimeInterval = (thread.mutedUntilTimestamp ?? 0) @@ -1157,9 +1166,9 @@ internal extension Interaction { guard !result.isEmpty else { return nil } - /// If we don't have a search term then return the value, otherwise highlight the search term tokens + /// If we don't have a search term then return the value (deformatted), otherwise highlight the search term tokens guard let searchText: String = searchText else { - return result + return result.deformatted() } return GlobalSearch.highlightSearchText( diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 4077e87011..8895b39d93 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -109,6 +109,10 @@ public extension ObservableKey { static func groupMemberUpdated(profileId: String, threadId: String) -> ObservableKey { ObservableKey("groupMemberUpdated-\(threadId)-\(profileId)", .groupMemberUpdated) } + + static func anyGroupMemberDeleted(threadId: String) -> ObservableKey { + ObservableKey("anyGroupMemberDeleted-\(threadId)", .anyGroupMemberDeleted) + } static func groupMemberDeleted(profileId: String, threadId: String) -> ObservableKey { ObservableKey("groupMemberDeleted-\(threadId)-\(profileId)", .groupMemberDeleted) } @@ -141,6 +145,7 @@ public extension GenericObservableKey { static let groupInfo: GenericObservableKey = "groupInfo" static let groupMemberCreated: GenericObservableKey = "groupMemberCreated" static let groupMemberUpdated: GenericObservableKey = "groupMemberUpdated" + static let anyGroupMemberDeleted: GenericObservableKey = "anyGroupMemberDeleted" static let groupMemberDeleted: GenericObservableKey = "groupMemberDeleted" } @@ -392,7 +397,11 @@ public extension ObservingDatabase { switch type { case .created: addEvent(ObservedEvent(key: .groupMemberCreated(threadId: threadId), value: event)) case .updated: addEvent(ObservedEvent(key: .groupMemberUpdated(profileId: profileId, threadId: threadId), value: event)) - case .deleted: addEvent(ObservedEvent(key: .groupMemberDeleted(profileId: profileId, threadId: threadId), value: event)) + case .deleted: + /// When a group member is deleted we need to emit both a profile+thread-specific event and a thread-specific event + /// as the message list screen will only observe the thread-specific one to update user count metadata + addEvent(ObservedEvent(key: .anyGroupMemberDeleted(threadId: threadId), value: event)) + addEvent(ObservedEvent(key: .groupMemberDeleted(profileId: profileId, threadId: threadId), value: event)) } } } diff --git a/SessionUtilitiesKit/Database/Types/FetchableTriple.swift b/SessionUtilitiesKit/Database/Types/FetchableTriple.swift new file mode 100644 index 0000000000..b4d0ec030c --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/FetchableTriple.swift @@ -0,0 +1,19 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public typealias FetchableTripleConformance = (Sendable & Codable & Equatable & Hashable) + +public struct FetchableTriple: FetchableTripleConformance, FetchableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case first + case second + case third + } + + public let first: First + public let second: Second + public let third: Third +} From fa6efe5caabaed8758dcbcd90fa379ddac0ce516 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 12 Dec 2025 16:20:31 +1100 Subject: [PATCH 34/66] Updated to the latest libSession --- Session.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index aef5816ec5..ffc23df000 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -11312,7 +11312,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.7; + version = 1.5.8; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 09d331bf4f..875777c3c3 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "38baf3f75ba50e6ba3950caa5709a40971c13e89", - "version" : "1.5.7" + "revision" : "8101f907433df1e15d59dd031d7e1a9386f83bfc", + "version" : "1.5.8" } }, { From 24157d8297fb30c683db463e8dee306ff14ab43f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 12 Dec 2025 16:38:51 +1100 Subject: [PATCH 35/66] Fixed a few more bugs --- .../Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- Session/Shared/FullConversationCell.swift | 2 +- .../Config Handling/LibSession+UserProfile.swift | 8 +------- SessionMessagingKit/Types/ConversationDataHelper.swift | 5 +++++ SessionUIKit/Types/BuildVariant.swift | 1 + 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index b263971fa5..e2de6e8cee 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -371,7 +371,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi private static func sections(state: State, viewModel: ThreadSettingsViewModel) -> [SectionModel] { let threadDisplayName: String = state.threadInfo.displayName.deformatted() let isThreadHidden: Bool = ( - !state.threadInfo.shouldBeVisible && + !state.threadInfo.shouldBeVisible || state.threadInfo.pinnedPriority == LibSession.hiddenPriority ) let showThreadPubkey: Bool = ( diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 42037cc300..8a07b9e7a8 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -394,7 +394,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) accentLineView.alpha = (unreadCount > 0 ? 1 : 0) - isPinnedIcon.isHidden = (cellViewModel.pinnedPriority == 0) + isPinnedIcon.isHidden = (cellViewModel.pinnedPriority <= LibSession.visiblePriority) unreadCountView.isHidden = (unreadCount <= 0) unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) unreadCountLabel.text = (unreadCount <= 0 ? diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 266082fce9..0823951576 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -61,13 +61,7 @@ internal extension LibSessionCacheType { ) }(), proUpdate: { - guard - let proConfig: SessionPro.ProConfig = self.proConfig, - proConfig.rotatingPrivateKey.count >= 32, - let rotatingKeyPair: KeyPair = try? dependencies[singleton: .crypto].tryGenerate( - .ed25519KeyPair(seed: proConfig.rotatingPrivateKey.prefix(upTo: 32)) - ) - else { return .none } + guard let proConfig: SessionPro.ProConfig = self.proConfig else { return .none } let profileFeatures: SessionPro.ProfileFeatures = SessionPro.ProfileFeatures(user_profile_get_pro_features(conf)) diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 62656eb71f..3fbce24ec0 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -171,6 +171,11 @@ public extension ConversationDataHelper { /// Handle page loading events based on view context requirements.needsPageLoad = { + switch currentCache.context.source { + case .conversationSettings, .searchResults: return false /// No paging + case .messageList, .conversationList: break + } + /// If we need a full refresh then we also need to refetch the paged data in case the sorting changed if currentCache.context.requireFullRefresh { return true diff --git a/SessionUIKit/Types/BuildVariant.swift b/SessionUIKit/Types/BuildVariant.swift index 7958b20f2f..6491964539 100644 --- a/SessionUIKit/Types/BuildVariant.swift +++ b/SessionUIKit/Types/BuildVariant.swift @@ -13,6 +13,7 @@ public enum BuildVariant: Sendable, Equatable, CaseIterable, CustomStringConvert case fDroid case huawei + // stringlint:ignore_contents public static var current: BuildVariant { #if DEBUG return .development From 6aedc4b79b23fae86bbafbfaf71a763fcd08092e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 16 Dec 2025 09:13:11 +1100 Subject: [PATCH 36/66] Fixed an issue where the QueryRunner would never go out of scope --- SessionUtilitiesKit/Observations/ObservationBuilder.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SessionUtilitiesKit/Observations/ObservationBuilder.swift b/SessionUtilitiesKit/Observations/ObservationBuilder.swift index 1c07681024..b07ab3c354 100644 --- a/SessionUtilitiesKit/Observations/ObservationBuilder.swift +++ b/SessionUtilitiesKit/Observations/ObservationBuilder.swift @@ -209,6 +209,10 @@ private actor QueryRunner { /// Keep the `QueryRunner` alive until it's parent task is cancelled await TaskCancellation.wait() + + /// Cleanup resources immediately upon cancellation + listenerTask?.cancel() + await debouncer.reset() } private func process(events: [ObservedEvent], isInitialQuery: Bool) async { From ce773c12664ac652c68faa5e0c650654df2d64cf Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 16 Dec 2025 09:20:19 +1100 Subject: [PATCH 37/66] Added in a couple of TODOs and simple cancellation logic --- .../SessionPro/SessionProManager.swift | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 507efd4ac2..b242a4a090 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -29,6 +29,7 @@ public enum SessionPro { public actor SessionProManager: SessionProManagerType { private let dependencies: Dependencies nonisolated private let syncState: SessionProManagerSyncState + private var revocationListTask: Task? private var transactionObservingTask: Task? private var proMockingObservationTask: Task? @@ -61,6 +62,7 @@ public actor SessionProManager: SessionProManagerType { Task { await updateWithLatestFromUserConfig() + await startRevocationListTask() await startTransactionObservation() await startProMockingObservations() @@ -72,6 +74,7 @@ public actor SessionProManager: SessionProManagerType { } deinit { + revocationListTask?.cancel() transactionObservingTask?.cancel() proMockingObservationTask?.cancel() } @@ -436,7 +439,7 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Pro State Management - public func refreshProState() async throws { + public func refreshProState(forceLoadingState: Bool) async throws { /// No point refreshing the state if there is a refresh in progress guard !isRefreshingState else { return } @@ -447,7 +450,7 @@ public actor SessionProManager: SessionProManagerType { var oldState: SessionPro.State = await stateStream.getCurrent() var updatedState: SessionPro.State = oldState - if oldState.loadingState == .error { + if forceLoadingState || oldState.loadingState == .error { updatedState = oldState.with( loadingState: .set(to: .loading), using: dependencies @@ -687,11 +690,27 @@ public actor SessionProManager: SessionProManagerType { } public func cancelPro(scene: UIWindowScene) async throws { - // TODO: [PRO] Need to add this + do { + try await AppStore.showManageSubscriptions(in: scene) + + // TODO: [PRO] Is there anything else we need to do here? Can we detect what the user did? (eg. via the transaction observation or something similar) + /// Need to refresh the pro state in case the user cancelled their pro (force the UI into the "loading" state just to be sure) + try await refreshProState(forceLoadingState: true) + } + catch { + // TODO: [PRO] Better errors? + throw NetworkError.explicit("Unable to show manage subscriptions: \(error)") + } } // MARK: - Internal Functions + private func startRevocationListTask() { + revocationListTask = Task { + // TODO: [PRO] Need to add in the logic for fetching, storing and updating the revocation list + } + } + private func startTransactionObservation() { transactionObservingTask = Task { for await result in Transaction.updates { @@ -779,11 +798,17 @@ public protocol SessionProManagerType: SessionProUIManagerType { func purchasePro(productId: String) async throws func addProPayment(transactionId: String) async throws - func refreshProState() async throws + func refreshProState(forceLoadingState: Bool) async throws func requestRefund(scene: UIWindowScene) async throws func cancelPro(scene: UIWindowScene) async throws } +public extension SessionProManagerType { + func refreshProState() async throws { + try await refreshProState(forceLoadingState: false) + } +} + // MARK: - Observations // stringlint:ignore_contents From 03df067dc8ae4f2d1dd11cb1fbb4869a6c492c4a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 17 Dec 2025 13:10:34 +1100 Subject: [PATCH 38/66] Fixed some build errors, resolved some TODOs, fixed a couple of bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added logic to handle non-originating accounts • Added explicit SessionPro errors • Moved profile pro decisions into the SessionProManager to consolidate the logic • Resolved a bunch of TODOs • Fixed an issue where a invalid pro key would prevent messages from decoding --- Session.xcodeproj/project.pbxproj | 14 +- .../Conversations/ConversationViewModel.swift | 2 +- .../Settings/ThreadSettingsViewModel.swift | 48 +++--- Session/Home/HomeViewModel.swift | 3 +- .../MessageInfoScreen.swift | 8 +- Session/Meta/Translations/InfoPlist.xcstrings | 30 ++++ .../DeveloperSettingsProViewModel.swift | 22 +-- .../DeveloperSettingsViewModel.swift | 10 -- .../UIContextualAction+Utilities.swift | 3 +- .../Crypto/Crypto+LibSession.swift | 6 +- .../Database/Models/Profile.swift | 2 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../SessionPro/SessionProManager.swift | 163 +++++++++++++----- .../SessionProPaymentScreenContent.swift | 66 ++----- .../SessionProSettingsViewModel.swift | 37 ++-- .../SessionPro/Types/SessionProError.swift | 45 +++++ .../Types/SessionProMessageFeatures.swift | 2 +- .../SessionPro/Types/SessionProState.swift | 25 ++- .../Utilities/SessionPro+Convenience.swift | 15 +- .../Types/ConversationInfoViewModel.swift | 41 ++--- .../Types/MessageViewModel.swift | 44 +---- .../ProfilePictureView+Convenience.swift | 10 +- .../Components/Input View/InputView.swift | 3 +- .../SessionProPaymentScreen+Models.swift | 10 +- .../SessionProPaymentScreen.swift | 77 +++++---- .../SessionProSettings+ProFeatures.swift | 5 +- .../Types/SessionProManagerType.swift | 150 ---------------- .../AttachmentApprovalViewController.swift | 18 +- 28 files changed, 396 insertions(+), 465 deletions(-) delete mode 100644 SessionUtilitiesKit/Types/SessionProManagerType.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 9eae7b6f75..d54d893f02 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -203,7 +203,6 @@ 945E89D22E95D54700D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */; }; 945E89D42E95D97000D8D907 /* SessionProPaymentScreen+SharedViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */; }; 945E89D62E9602AB00D8D907 /* SessionProPaymentScreen+Purchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */; }; - 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946379492E71308B0017A014 /* SessionProManagerType.swift */; }; 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; @@ -219,7 +218,6 @@ 94805EB22EB087FD0055EBBC /* BottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB12EB087F90055EBBC /* BottomSheet.swift */; }; 94805EB92EB1E16D0055EBBC /* SessionProSettings+ProFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */; }; 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EBE2EB462C10055EBBC /* TransitionType.swift */; }; - 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */; }; 94805EC32EB48ED50055EBBC /* SessionProPaymentScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */; }; 94805EC62EB823B80055EBBC /* DismissType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC52EB823B00055EBBC /* DismissType.swift */; }; 94805EC82EB834D40055EBBC /* UINavigationController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */; }; @@ -514,6 +512,7 @@ FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7AD27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift */; }; FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E427F6A09900122BE0 /* Identity.swift */; }; + FD184C232EF2100D001089EB /* SessionProError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD184C222EF2100A001089EB /* SessionProError.swift */; }; FD19363C2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */; }; FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */; }; FD1A553E2E14BE11003761E4 /* PagedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A553D2E14BE0E003761E4 /* PagedData.swift */; }; @@ -1722,7 +1721,6 @@ 945E89D12E95D54000D8D907 /* SessionProPaymentScreen+NoBillingAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+NoBillingAccess.swift"; sourceTree = ""; }; 945E89D32E95D96100D8D907 /* SessionProPaymentScreen+SharedViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+SharedViews.swift"; sourceTree = ""; }; 945E89D52E96028B00D8D907 /* SessionProPaymentScreen+Purchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Purchase.swift"; sourceTree = ""; }; - 946379492E71308B0017A014 /* SessionProManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProManagerType.swift; sourceTree = ""; }; 9463794B2E7137120017A014 /* SessionProPaymentScreen+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProPaymentScreen+Models.swift"; sourceTree = ""; }; 9471CAA72CACFB4E00090FB7 /* GenerateLicenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerateLicenses.swift; sourceTree = ""; }; 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -1742,7 +1740,6 @@ 94805EB12EB087F90055EBBC /* BottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomSheet.swift; sourceTree = ""; }; 94805EB82EB1E1650055EBBC /* SessionProSettings+ProFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProSettings+ProFeatures.swift"; sourceTree = ""; }; 94805EBE2EB462C10055EBBC /* TransitionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionType.swift; sourceTree = ""; }; - 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProState+Models.swift"; sourceTree = ""; }; 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProPaymentScreenContent.swift; sourceTree = ""; }; 94805EC52EB823B00055EBBC /* DismissType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissType.swift; sourceTree = ""; }; 94805EC72EB834CD0055EBBC /* UINavigationController+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Utilities.swift"; sourceTree = ""; }; @@ -2045,6 +2042,7 @@ FD17D7C927F546D900122BE0 /* _001_SUK_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_SUK_InitialSetupMigration.swift; sourceTree = ""; }; FD17D7E427F6A09900122BE0 /* Identity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Identity.swift; sourceTree = ""; }; FD17D7E627F6A16700122BE0 /* _003_SUK_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_SUK_YDBToGRDBMigration.swift; sourceTree = ""; }; + FD184C222EF2100A001089EB /* SessionProError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProError.swift; sourceTree = ""; }; FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = ""; }; FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = ""; }; FD1A553D2E14BE0E003761E4 /* PagedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedData.swift; sourceTree = ""; }; @@ -3243,8 +3241,6 @@ 948615C02ED7D39B000A5666 /* SessionProSettingsViewModel.swift */, 948615C12ED7D39B000A5666 /* SessionProSettingsViewModel+Database.swift */, 94805EC22EB48EC40055EBBC /* SessionProPaymentScreenContent.swift */, - 94B6BAF52E30A88800E718BB /* SessionProState.swift */, - 94805EC02EB48D860055EBBC /* SessionProState+Models.swift */, ); path = SessionPro; sourceTree = ""; @@ -4566,7 +4562,6 @@ FD2272D22C34ECBB004D8A6C /* Types */ = { isa = PBXGroup; children = ( - 946379492E71308B0017A014 /* SessionProManagerType.swift */, FD39370B2E4D7BBE00571F17 /* DocumentPickerHandler.swift */, FD0F85722EA83C41004E0B98 /* AnyCodable.swift */, FD0E353A2AB98773006A81F7 /* AppVersion.swift */, @@ -5373,6 +5368,7 @@ FD360EBE2ECAD5160050CAF4 /* SessionProConfig.swift */, FDAA36CD2EB484450040603E /* SessionProDecodedProForMessage.swift */, FDAA36CF2EB485EF0040603E /* SessionProDecodedStatus.swift */, + FD184C222EF2100A001089EB /* SessionProError.swift */, FDAA36C52EB474C40040603E /* SessionProFeaturesForMessage.swift */, FDAA36C72EB475140040603E /* SessionProFeatureStatus.swift */, FD360ECE2ECEE5F20050CAF4 /* SessionProLoadingState.swift */, @@ -7185,7 +7181,6 @@ FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */, FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */, FD5931A72A8DA5DA0040147D /* SQLInterpolation+Utilities.swift in Sources */, - 9463794A2E7131070017A014 /* SessionProManagerType.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, FD3765EA2ADE37B400DC1489 /* Authentication.swift in Sources */, FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */, @@ -7397,6 +7392,7 @@ FD2CFB992EDFF32E00EC7F98 /* ConversationDataCache.swift in Sources */, FD99A3A42EBAA6BD00E59F94 /* EnvelopeFlags.swift in Sources */, C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */, + FD184C232EF2100D001089EB /* SessionProError.swift in Sources */, C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, @@ -7406,8 +7402,6 @@ FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FD99A3A22EBAA6AA00E59F94 /* Envelope.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, - FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - 94805EC12EB48D910055EBBC /* SessionProState+Models.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD2272712C32911C004D8A6C /* MessageReceiveJob.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 57bf49e2df..53e5de95f1 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -940,7 +940,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let optimisticMessageId: Int64 = (-Int64.max + sentTimestampMs) /// Unique but avoids collisions with messages let currentState: State = await self.state let proMessageFeatures: SessionPro.MessageFeatures = try { - let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features( + let result: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures( for: (text ?? "") ) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 897b7f14b9..14437645db 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -174,7 +174,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi public struct State: ObservableKeyProvider { let profileImageStatus: ProfileImageStatus - let isProConversation: Bool = false // TODO: [PRO] Need to determine whether it's a PRO group conversation let threadInfo: ConversationInfoViewModel let dataCache: ConversationDataCache @@ -515,7 +514,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .group: return .groupLimit( isAdmin: (state.threadInfo.groupInfo?.currentUserRole == .admin), - isSessionProActivated: state.isProConversation, + isSessionProActivated: (state.threadInfo.groupInfo?.isProGroup == true), proBadgeImage: UIView.image( for: .themedKey( SessionProBadge.Size.mini.cacheKey, @@ -525,7 +524,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi ) ) default: - return .generic(renew: dependencies[singleton: .sessionProState].isSessionProExpired) + return .generic( + renew: dependencies[singleton: .sessionProManager] + .currentUserCurrentProState + .status == .expired + ) } }() @@ -533,8 +536,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi proCTAModalVariant, onConfirm: { dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( - presenting: { bottomSheet in - self?.transitionToScreen(bottomSheet, transitionType: .present) + presenting: { [weak viewModel] bottomSheet in + viewModel?.transitionToScreen(bottomSheet, transitionType: .present) } ) }, @@ -2229,26 +2232,23 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi /// If we already have too many conversations pinned then we need to show the CTA modal guard numPinnedConversations > 0 else { return } - await MainActor.run { [weak self, dependencies] in - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - variant: .morePinnedConvos( - isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit), - renew: (sessionProState.status == .expired) - ), - dataManager: dependencies[singleton: .imageDataManager], - sessionProUIManager: dependencies[singleton: .sessionProManager], - onConfirm: { [dependencies] in - // TODO: [PRO] Need to sort this out - dependencies[singleton: .sessionProState].upgradeToPro( - plan: SessionProPlan(variant: .threeMonths), - originatingPlatform: .iOS, - completion: nil - ) - } - ) + _ = await MainActor.run { [weak self, dependencies] in + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + .morePinnedConvos( + isGrandfathered: (numPinnedConversations > SessionPro.PinnedConversationLimit), + renew: (sessionProState.status == .expired) + ), + onConfirm: { [weak self] in + dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + presenting: { [weak self] bottomSheet in + self?.transitionToScreen(bottomSheet, transitionType: .present) + } + ) + }, + presenting: { [weak self] modal in + self?.transitionToScreen(modal, transitionType: .present) + } ) - self?.transitionToScreen(sessionProModal, transitionType: .present) } } catch {} diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 343f590787..2943390312 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -542,7 +542,8 @@ public class HomeViewModel: NavigatableStateHolder { flow: info.paymentFlow, plans: info.planInfo ), - dependencies: dependencies + isFromBottomSheet: false, + using: dependencies ) ) ) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index f105f71e3b..68f9d01c0a 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -37,7 +37,7 @@ struct MessageInfoScreen: View { case .increasedMessageLength: return .longerMessages(renew: (currentUserProStatus == .expired)) case .animatedDisplayPicture: return .animatedProfileImage( - isSessionProActivated: currentUserIsPro, + isSessionProActivated: (currentUserProStatus == .active), renew: (currentUserProStatus == .expired) ) } @@ -465,7 +465,7 @@ struct MessageInfoScreen: View { if viewModel.shouldShowProBadge { SessionProBadge_SwiftUI(size: .small) - .onTapGesture { showSessionProCTAIfNeeded } + .onTapGesture { showSessionProCTAIfNeeded() } } } @@ -579,14 +579,14 @@ struct MessageInfoScreen: View { } private func showSessionProCTAIfNeeded() { - dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( + viewModel.dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( viewModel.ctaVariant( currentUserProStatus: viewModel.dependencies[singleton: .sessionProManager] .currentUserCurrentProState .status ), onConfirm: { - dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + viewModel.dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( presenting: { bottomSheet in self.host.controller?.present(bottomSheet, animated: true) } diff --git a/Session/Meta/Translations/InfoPlist.xcstrings b/Session/Meta/Translations/InfoPlist.xcstrings index 46511db12c..dc9e363720 100644 --- a/Session/Meta/Translations/InfoPlist.xcstrings +++ b/Session/Meta/Translations/InfoPlist.xcstrings @@ -1504,6 +1504,12 @@ "NSLocalNetworkUsageDescription" : { "extractionState" : "manual", "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتطلب Session الوصول إلى الشبكة المحلية لإجراء المكالمات الصوتية ومكالمات الفيديو." + } + }, "az" : { "stringUnit" : { "state" : "translated", @@ -1600,18 +1606,42 @@ "value" : "Session이 음성 및 영상 통화를 하기 위해 로컬 네트워크에 접근해야 합니다." } }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, + "nb-NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Session heeft toegang nodig tot het lokale netwerk om spraak- en videogesprekken uit te voeren." } }, + "nn-NO" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session må ha tilgang til det lokale nettverket for å kunne foreta tale- og videosamtaler." + } + }, "pl" : { "stringUnit" : { "state" : "translated", "value" : "Session potrzebuje dostępu do sieci lokalnej, aby wykonywać połączenia głosowe i wideo." } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session precisa de acesso à rede local para realizar chamadas de voz e vídeo." + } + }, "pt-PT" : { "stringUnit" : { "state" : "translated", diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 8d38fc2ce3..72570203f2 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -186,8 +186,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let forceMessageFeatureProBadge: Bool let forceMessageFeatureLongMessage: Bool let forceMessageFeatureAnimatedAvatar: Bool - // TODO: [PRO] Add these back -// let proPlanToRecover: Bool let products: [Product] let purchasedProduct: Product? @@ -809,24 +807,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } - private func purchaseSubscription() async { - do { - let products: [Product] = try await Product.products(for: ["com.getsession.org.pro_sub"]) - - Task.detached(priority: .userInitiated) { [dependencies] in - try? await dependencies[singleton: .sessionProManager].refreshProState() - } - } - - if dependencies.hasSet(feature: .mockCurrentUserSessionProLoadingState) { - dependencies.reset(feature: .mockCurrentUserSessionProLoadingState) - - Task.detached(priority: .userInitiated) { [dependencies] in - try? await dependencies[singleton: .sessionProManager].refreshProState() - } - } - } - // MARK: - Pro Requests private func purchaseSubscription(currentProduct: Product?) async { @@ -989,7 +969,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let transactionId: String = try await { guard await internalState.fakeAppleSubscriptionForDev else { guard let transaction: Transaction = await internalState.purchaseTransaction else { - throw NetworkError.explicit("No Transaction") + throw SessionProError.transactionNotFound } return "\(transaction.id)" diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 73123f527a..33db540b70 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -2243,13 +2243,3 @@ extension Network.PushNotification.Service: Listable {} extension Log.Level: @retroactive ContentIdentifiable {} extension Log.Level: @retroactive ContentEquatable {} extension Log.Level: Listable {} -// TODO: [PRO] Need to sort these out -//extension SessionProStateMock: @retroactive ContentIdentifiable {} -//extension SessionProStateMock: @retroactive ContentEquatable {} -//extension SessionProStateMock: Listable {} -//extension SessionProLoadingState: @retroactive ContentIdentifiable {} -//extension SessionProLoadingState: @retroactive ContentEquatable {} -//extension SessionProLoadingState: Listable {} -//extension SessionProStateExpiryMock: @retroactive ContentIdentifiable {} -//extension SessionProStateExpiryMock: @retroactive ContentEquatable {} -//extension SessionProStateExpiryMock: Listable {} diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 0eb890bd77..48783d686c 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -240,7 +240,7 @@ public extension UIContextualAction { }), pinnedConversationsNumber >= SessionPro.PinnedConversationLimit { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( .morePinnedConvos( isGrandfathered: (pinnedConversationsNumber >= SessionPro.PinnedConversationLimit), renew: (dependencies[singleton: .sessionProManager] @@ -273,6 +273,7 @@ public extension UIContextualAction { try SessionThread.updateVisibility( db, threadId: threadInfo.id, + threadVariant: threadInfo.variant, isVisible: true, customPriority: (isCurrentlyPinned ? LibSession.visiblePriority : 1), using: dependencies diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 77b1904f82..4aaf6693f5 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -153,7 +153,7 @@ public extension Crypto.Generator { cEncodedMessage, cEncodedMessage.count, sentTimestampMs, - cBackendPubkey, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), cBackendPubkey.count, &error, error.count @@ -187,7 +187,7 @@ public extension Crypto.Generator { cPlaintext, cPlaintext.count, sentTimestampMs, - cBackendPubkey, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), cBackendPubkey.count, &error, error.count @@ -256,7 +256,7 @@ public extension Crypto.Generator { &cKeys, cEncodedMessage, cEncodedMessage.count, - cBackendPubkey, + (cBackendPubkey.isEmpty ? nil : cBackendPubkey), cBackendPubkey.count, &error, error.count diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index f3ae345614..2c8e82b2bc 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -59,7 +59,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// The unix timestamp (in milliseconds) when Session Pro expires for this profile public let proExpiryUnixTimestampMs: UInt64 - /// The timestamp when Session Pro expires for this profile + /// Hash of the generation index for this users Session Pro public let proGenIndexHashHex: String? // MARK: - Initialization diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 8394ccc49c..4e744234e0 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -720,7 +720,7 @@ extension MessageReceiver { guard let text: String = text else { return nil } /// Extract the features used for the message - let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].features(for: text) + let info: SessionPro.FeaturesForMessage = dependencies[singleton: .sessionProManager].messageFeatures(for: text) let proStatus: SessionPro.DecodedStatus? = dependencies[singleton: .sessionProManager].proStatus( for: decodedMessage.decodedPro?.proProof, verifyPubkey: { diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index f3d324a07f..2967af91e6 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -31,6 +31,7 @@ public actor SessionProManager: SessionProManagerType { nonisolated private let syncState: SessionProManagerSyncState private var revocationListTask: Task? private var transactionObservingTask: Task? + private var entitlementsObservingTask: Task? private var proMockingObservationTask: Task? private var isRefreshingState: Bool = false @@ -60,15 +61,15 @@ public actor SessionProManager: SessionProManagerType { self.dependencies = dependencies self.syncState = SessionProManagerSyncState(using: dependencies) - Task { - await updateWithLatestFromUserConfig() - await startRevocationListTask() - await startTransactionObservation() - await startProMockingObservations() + Task.detached(priority: .medium) { [weak self] in + await self?.updateWithLatestFromUserConfig() + await self?.startRevocationListTask() + await self?.startStoreKitObservations() + await self?.startProMockingObservations() /// Kick off a refresh so we know we have the latest state (if it's the main app) if dependencies[singleton: .appContext].isMainApp { - try? await refreshProState() + try? await self?.refreshProState() } } } @@ -76,13 +77,14 @@ public actor SessionProManager: SessionProManagerType { deinit { revocationListTask?.cancel() transactionObservingTask?.cancel() + entitlementsObservingTask?.cancel() proMockingObservationTask?.cancel() } // MARK: - Functions nonisolated public func numberOfCharactersLeft(for content: String) -> Int { - let features: SessionPro.FeaturesForMessage = features(for: content) + let features: SessionPro.FeaturesForMessage = messageFeatures(for: content) switch features.status { case .utfDecodingError: @@ -126,7 +128,7 @@ public actor SessionProManager: SessionProManagerType { return session_protocol_pro_proof_is_active(&cProProof, timestampMs) } - nonisolated public func features(for message: String) -> SessionPro.FeaturesForMessage { + nonisolated public func messageFeatures(for message: String) -> SessionPro.FeaturesForMessage { guard let cMessage: [CChar] = message.cString(using: .utf8) else { return SessionPro.FeaturesForMessage.invalidString } @@ -139,8 +141,46 @@ public actor SessionProManager: SessionProManagerType { ) } + nonisolated public func profileFeatures(for profile: Profile?) -> SessionPro.ProfileFeatures { + guard syncState.dependencies[feature: .sessionProEnabled] else { return .none } + guard let profile else { + /// If we are forcing the pro badge to appear everywhere then insert it + if syncState.dependencies[feature: .proBadgeEverywhere] { + return .proBadge + } + + return .none + } + + var result: SessionPro.ProfileFeatures = profile.proFeatures + + /// Check if the pro status on the profile has expired (if so clear the features) + switch (profile.proGenIndexHashHex, profile.proExpiryUnixTimestampMs) { + case (.some(let proGenIndexHashHex), let expiryUnixTimestampMs) where expiryUnixTimestampMs > 0: + // TODO: [PRO] Need to check the `proGenIndexHashHex` against the revocation list to see if the user still has pro + let proWasRevoked: Bool = false + let proHasExpired: Bool = (syncState.dependencies.dateNow.timeIntervalSince1970 > (Double(expiryUnixTimestampMs) / 1000)) + + if proWasRevoked || proHasExpired { + result = .none + } + + + /// If we don't have either `proExpiryUnixTimestampMs` or `proGenIndexHashHex` then the pro state is invalid + /// so the user shouldn't have any pro features + default: result = .none + } + + /// If we are forcing the pro badge to appear everywhere then insert it + if syncState.dependencies[feature: .proBadgeEverywhere] { + result.insert(.proBadge) + } + + return result + } + nonisolated public func attachProInfoIfNeeded(message: Message) -> Message { - let featuresForMessage: SessionPro.FeaturesForMessage = features( + let featuresForMessage: SessionPro.FeaturesForMessage = messageFeatures( for: ((message as? VisibleMessage)?.text ?? "") ) let profileFeatures: SessionPro.ProfileFeatures = syncState.state.profileFeatures @@ -201,6 +241,25 @@ public actor SessionProManager: SessionProManagerType { return true } + @MainActor public func showSessionProBottomSheetIfNeeded( + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) { + let viewModel: SessionProSettingsViewModel = SessionProSettingsViewModel( + isInBottomSheet: true, + using: syncState.dependencies + ) + let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController( + bottomSheet: BottomSheet( + hasCloseButton: true, + afterClosed: afterClosed + ) { + SessionListScreen(viewModel: viewModel) + } + ) + presenting?(sessionProBottomSheet) + } + public func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? { let state: SessionPro.State = await stateStream.getCurrent() let dateNow: Date = dependencies.dateNow @@ -319,27 +378,23 @@ public actor SessionProManager: SessionProManagerType { guard let product: Product = state.products.first(where: { $0.id == productId }) else { Log.error(.sessionPro, "Attempted to purchase invalid product: \(productId)") - // TODO: [PRO] Better errors - throw NetworkError.explicit("Unable to find product to purchase") + throw SessionProError.productNotFound } - // TODO: [PRO] This results in an error being logged: "Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch." let result: Product.PurchaseResult = try await product.purchase() guard case .success(let verificationResult) = result else { switch result { - case .success: - // TODO: [PRO] Better errors - throw NetworkError.explicit("Invalid Case") + case .success: throw SessionProError.unhandledBehaviour /// Invalid case case .pending: - // TODO: [PRO] How do we handle this? Let the user continue what they were doing and listen for transaction updates? What if they restart the app??? - throw NetworkError.explicit("TODO: Pending transaction") + // TODO: [PRO] Need to handle this case, new designs are now available (the `transactionObservingTask` will detect this case) + throw SessionProError.unhandledBehaviour - case .userCancelled: throw NetworkError.explicit("User Cancelled") + case .userCancelled: throw SessionProError.purchaseCancelled @unknown default: - // TODO: [PRO] Better errors - throw NetworkError.explicit("An unhandled purchase result was received: \(result)") + Log.critical(.sessionPro, "An unhandled purchase result was received: \(result)") + throw SessionProError.unhandledBehaviour } } @@ -392,7 +447,7 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { // TODO: [PRO] Need to show the error modal let errorString: String = response.header.errors.joined(separator: ", ") - throw NetworkError.explicit(errorString) + throw SessionProError.purchaseFailed(errorString) } /// Update the config @@ -439,6 +494,11 @@ public actor SessionProManager: SessionProManagerType { // MARK: - Pro State Management + private func updateProState(to newState: SessionPro.State) async { + syncState.update(state: .set(to: newState)) + await self.stateStream.send(newState) + } + public func refreshProState(forceLoadingState: Bool) async throws { /// No point refreshing the state if there is a refresh in progress guard !isRefreshingState else { return } @@ -498,7 +558,7 @@ public actor SessionProManager: SessionProManagerType { syncState.update(state: .set(to: updatedState)) await self.stateStream.send(updatedState) - throw NetworkError.explicit(errorString) + throw SessionProError.getProDetailsFailed(errorString) } updatedState = oldState.with( status: .set(to: response.status), @@ -579,7 +639,7 @@ public actor SessionProManager: SessionProManagerType { guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") Log.error(.sessionPro, "Failed to generate new pro proof due to error(s): \(errorString)") - throw NetworkError.explicit(errorString) + throw SessionProError.generateProProofFailed(errorString) } /// Update the config @@ -627,28 +687,27 @@ public actor SessionProManager: SessionProManagerType { try await refreshProState(forceLoadingState: true) } catch { - // TODO: [PRO] Better errors? - throw NetworkError.explicit("Unable to show manage subscriptions: \(error)") + throw SessionProError.failedToShowStoreKitUI("Manage Subscriptions") } } @MainActor public func requestRefund(scene: UIWindowScene) async throws { guard let latestPaymentItem: Network.SessionPro.PaymentItem = await stateStream.getCurrent().latestPaymentItem else { - throw NetworkError.explicit("No latest payment item") + throw SessionProError.noLatestPaymentItem } /// User has already requested a refund for this item guard latestPaymentItem.refundRequestedTimestampMs == 0 else { - throw NetworkError.explicit("Refund already requested for latest payment") + throw SessionProError.refundAlreadyRequestedForLatestPayment } /// Only Apple support refunding via this mechanism so no point continuing if we don't have a `appleTransactionId` guard let transactionId: String = latestPaymentItem.appleTransactionId else { - throw NetworkError.explicit("Latest payment wasn't originated from an Apple device") + throw SessionProError.nonOriginatedLatestPayment } /// If we don't have the `fakeAppleSubscriptionForDev` feature enabled then we need to actually request the refund from Apple - if !dependencies[feature: .fakeAppleSubscriptionForDev] { + if !syncState.dependencies[feature: .fakeAppleSubscriptionForDev] { var transactions: [Transaction] = [] for await result in Transaction.currentEntitlements { @@ -667,36 +726,38 @@ public actor SessionProManager: SessionProManagerType { /// Prioritise the transaction that matches the latest payment item guard let targetTransaction: Transaction = (latestPaymentItemTransaction ?? latestTransaction) else { - throw NetworkError.explicit("No Transaction") + throw SessionProError.transactionNotFound } let status: Transaction.RefundRequestStatus = try await targetTransaction.beginRefundRequest(in: scene) switch status { case .success: break /// Continue on to send the refund to our backend - case .userCancelled: throw NetworkError.explicit("Cancelled refund request") - @unknown default: throw NetworkError.explicit("Unknown refund request status") + case .userCancelled: throw SessionProError.refundCancelled + @unknown default: + Log.critical(.sessionPro, "Unknown refund request status: \(status)") + throw SessionProError.unhandledBehaviour } } - let refundRequestedTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + let refundRequestedTimestampMs: UInt64 = syncState.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let request = try Network.SessionPro.setPaymentRefundRequested( transactionId: transactionId, refundRequestedTimestampMs: refundRequestedTimestampMs, - masterKeyPair: try dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), - using: dependencies + masterKeyPair: try syncState.dependencies[singleton: .crypto].tryGenerate(.sessionProMasterKeyPair()), + using: syncState.dependencies ) // FIXME: Make this async/await when the refactored networking is merged let response: Network.SessionPro.SetPaymentRefundRequestedResponse = try await request - .send(using: dependencies) + .send(using: syncState.dependencies) .values .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() guard response.header.errors.isEmpty else { let errorString: String = response.header.errors.joined(separator: ", ") Log.error(.sessionPro, "Refund submission failed due to error(s): \(errorString)") - throw NetworkError.explicit(errorString) + throw SessionProError.refundFailed(errorString) } /// Need to refresh the pro state to get the updated payment item (which should now include a `refundRequestedTimestampMs`) @@ -711,7 +772,7 @@ public actor SessionProManager: SessionProManagerType { } } - private func startTransactionObservation() { + private func startStoreKitObservations() { transactionObservingTask = Task { for await result in Transaction.updates { do { @@ -730,6 +791,29 @@ public actor SessionProManager: SessionProManagerType { } } } + + // TODO: [PRO] Do we want this to run in a loop with a sleep in case the user purchases pro on another device? + entitlementsObservingTask = Task { [weak self] in + guard let self else { return } + + var currentEntitledTransactions: [Transaction] = [] + + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { continue } + + /// Ensure it's a subscription product + guard transaction.productType == .autoRenewable else { continue } + + currentEntitledTransactions.append(transaction) + } + + let oldState: SessionPro.State = await stateStream.getCurrent() + let updatedState: SessionPro.State = oldState.with( + entitledTransactions: .set(to: currentEntitledTransactions), + using: syncState.dependencies + ) + await updateProState(to: updatedState) + } } private func clearProProofFromConfig() async throws { @@ -788,7 +872,8 @@ public protocol SessionProManagerType: SessionProUIManagerType { for proof: Network.SessionPro.ProProof?, atTimestampMs timestampMs: UInt64 ) -> Bool - nonisolated func features(for message: String) -> SessionPro.FeaturesForMessage + nonisolated func messageFeatures(for message: String) -> SessionPro.FeaturesForMessage + nonisolated func profileFeatures(for profile: Profile?) -> SessionPro.ProfileFeatures nonisolated func attachProInfoIfNeeded(message: Message) -> Message func sessionProExpiringCTAInfo() async -> (variant: ProCTAModal.Variant, paymentFlow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow, planInfo: [SessionProPaymentScreenContent.SessionProPlanInfo])? diff --git a/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift index 70c3aed9e2..8630fd9f70 100644 --- a/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift +++ b/SessionMessagingKit/SessionPro/SessionProPaymentScreenContent.swift @@ -21,62 +21,30 @@ extension SessionProPaymentScreenContent { self.isFromBottomSheet = isFromBottomSheet } - @MainActor public func purchase( - planInfo: SessionProPlanInfo, - success: (@MainActor () -> Void)?, - failure: (@MainActor () -> Void)? - ) { - Task(priority: .userInitiated) { - do { - try await dependencies[singleton: .sessionProManager].purchasePro( - productId: planInfo.id - ) - await MainActor.run { - success?() - } - } - catch { - await MainActor.run { - failure?() - } - } - } + @MainActor public func purchase(planInfo: SessionProPlanInfo) async throws { + try await Task.detached(priority: .userInitiated) { [dependencies] in + try await dependencies[singleton: .sessionProManager].purchasePro( + productId: planInfo.id + ) + }.value } - @MainActor public func cancelPro( - success: (@MainActor () -> Void)?, - failure: (@MainActor () -> Void)? - ) { - do { - guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { - failure?() - return Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") - } - - try await dependencies[singleton: .sessionProManager].cancelPro(scene: scene) - success?() - } - catch { - failure?() + @MainActor public func cancelPro(scene: UIWindowScene?) async throws { + guard let scene else { + Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + throw SessionProError.windowSceneRequired } + + try await dependencies[singleton: .sessionProManager].cancelPro(scene: scene) } - @MainActor public func requestRefund( - success: (@MainActor () -> Void)?, - failure: (@MainActor () -> Void)? - ) { - guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { - failure?() - return Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + @MainActor public func requestRefund(scene: UIWindowScene?) async throws { + guard let scene else { + Log.error(.sessionPro, "Failed to being refund request: Unable to get UIWindowScene") + throw SessionProError.windowSceneRequired } - do { - try await dependencies[singleton: .sessionProManager].requestRefund(scene: scene) - success?() - } - catch { - failure?() - } + try await dependencies[singleton: .sessionProManager].requestRefund(scene: scene) } } } diff --git a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift index bda7cb8a85..b4cf0f4757 100644 --- a/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift +++ b/SessionMessagingKit/SessionPro/SessionProSettingsViewModel.swift @@ -232,7 +232,6 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType proExpiryUnixTimestampMs: .set(to: expiryUnixTimestampMs), proGenIndexHashHex: .set(to: genIndexHashHex) ) - default: break } } @@ -329,14 +328,14 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType } }(), description: { - switch (state.currentProPlanState, state.isInBottomSheet) { + switch (state.proState.status, state.isInBottomSheet) { case (.expired, true): return "proAccessRenewStart" .put(key: "pro", value: Constants.pro) .put(key: "app_pro", value: Constants.app_pro) .localizedFormatted() - case (.none, _): + case (.neverBeenPro, _): return "proFullestPotential" .put(key: "app_name", value: Constants.app_name) .put(key: "app_pro", value: Constants.app_pro) @@ -395,8 +394,8 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType .put(key: "pro", value: Constants.pro) .localized(), description: { - switch state.currentProPlanState { - case .none: + switch state.proState.status { + case .neverBeenPro: "proStatusNetworkErrorContinue" .put(key: "pro", value: Constants.pro) .localizedFormatted() @@ -421,10 +420,10 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType id: .continueButton, variant: .button( title: "theContinue".localized(), - enabled: (state.loadingState == .success) + enabled: (state.proState.loadingState == .success) ), onTap: { [weak viewModel] in - switch state.loadingState { + switch state.proState.loadingState { case .success: viewModel?.updateProPlan(state: state) case .loading: viewModel?.showLoadingModal( @@ -620,7 +619,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType title: SessionListScreenContent.TextInfo( "proGroupsUpgraded" .putNumber(state.numberOfGroupsUpgraded) - .put(key: "total", value: (state.loadingState == .loading ? "" : state.numberOfGroupsUpgraded)) + .put(key: "total", value: (state.proState.loadingState == .loading ? "" : state.numberOfGroupsUpgraded)) .localized(), font: .Headings.H9, color: (state.proState.loadingState == .loading ? .textPrimary : .disabled) @@ -1090,17 +1089,7 @@ public class SessionProSettingsViewModel: SessionListScreenContent.ViewModelType ) ) ), - onTap: { [weak viewModel] in - Task { - await viewModel? - .dependencies[singleton: .sessionProState] - .recoverPro { [weak viewModel] result in - DispatchQueue.main.async { - viewModel?.recoverProPlanCompletionHandler(result) - } - } - } - } + onTap: { [weak viewModel] in viewModel?.recoverProPlan() } ) ] } @@ -1192,12 +1181,12 @@ extension SessionProSettingsViewModel { flow: SessionProPaymentScreenContent.SessionProPlanPaymentFlow(state: state.proState), plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), - isFromBottomSheet: state.isFromBottomSheet, - dependencies: dependencies + isFromBottomSheet: state.isInBottomSheet, + using: dependencies ) ) - guard !state.isFromBottomSheet else { + guard !state.isInBottomSheet else { self.transitionToScreen(paymentScreen, transitionType: .push) return } @@ -1276,7 +1265,7 @@ extension SessionProSettingsViewModel { plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), isFromBottomSheet: false, - dependencies: dependencies + using: dependencies ) ) ) @@ -1296,7 +1285,7 @@ extension SessionProSettingsViewModel { plans: state.proState.plans.map { SessionProPaymentScreenContent.SessionProPlanInfo(plan: $0) } ), isFromBottomSheet: false, - dependencies: dependencies + using: dependencies ) ) ) diff --git a/SessionMessagingKit/SessionPro/Types/SessionProError.swift b/SessionMessagingKit/SessionPro/Types/SessionProError.swift index e69de29bb2..78cd6b77c7 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProError.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProError.swift @@ -0,0 +1,45 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +public enum SessionProError: Error, CustomStringConvertible { + case productNotFound + case transactionNotFound + case purchaseCancelled + case refundCancelled + case windowSceneRequired + case failedToShowStoreKitUI(String) + + case purchaseFailed(String) + case refundFailed(String) + case generateProProofFailed(String) + case getProDetailsFailed(String) + + case noLatestPaymentItem + case refundAlreadyRequestedForLatestPayment + case nonOriginatedLatestPayment + + case unhandledBehaviour + + public var description: String { + switch self { + case .productNotFound: return "The request product was not found." + case .transactionNotFound: return "The transaction was not found." + case .purchaseCancelled: return "The purchase was cancelled." + case .refundCancelled: return "The refund was cancelled." + case .windowSceneRequired: return "A window scene is required to present the UI." + case .failedToShowStoreKitUI(let screen): return "Failed to show StoreKit UI: \(screen)." + + case .purchaseFailed(let error): return "The purchase failed due to error: \(error)." + case .refundFailed(let error): return "The refund failed due to error: \(error)." + case .generateProProofFailed(let error): return "Failed to generate the pro proof due to error: \(error)." + case .getProDetailsFailed(let error): return "Failed to get pro details due to error: \(error)." + + case .noLatestPaymentItem: return "No latest payment item." + case .refundAlreadyRequestedForLatestPayment: return "Refund already requested for latest payment" + case .nonOriginatedLatestPayment: return "Latest payment wasn't originated from an Apple device" + + case .unhandledBehaviour: return "Unhandled behaviour." + } + } +} diff --git a/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift index f9d8873947..cf03fd152a 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProMessageFeatures.swift @@ -23,7 +23,7 @@ public extension SessionPro { } // MARK: - Initialization - // TODO: [PRO] Might be good to actually test what happens if you put an unsupported value in here? (ie. does it get stripped when converting/storing?) + public init(rawValue: UInt64) { self.rawValue = rawValue } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProState.swift b/SessionMessagingKit/SessionPro/Types/SessionProState.swift index 522b579004..706921630a 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProState.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProState.swift @@ -15,6 +15,7 @@ public extension SessionPro { public let buildVariant: BuildVariant public let products: [Product] public let plans: [SessionPro.Plan] + public let entitledTransactions: [Transaction] public let loadingState: SessionPro.LoadingState public let status: Network.SessionPro.BackendUserProStatus @@ -36,6 +37,7 @@ public extension SessionPro.State { buildVariant: .appStore, products: [], plans: [], + entitledTransactions: [], loadingState: .loading, status: .neverBeenPro, proof: nil, @@ -53,6 +55,7 @@ internal extension SessionPro.State { func with( products: Update<[Product]> = .useExisting, plans: Update<[SessionPro.Plan]> = .useExisting, + entitledTransactions: Update<[Transaction]> = .useExisting, loadingState: Update = .useExisting, status: Update = .useExisting, proof: Update = .useExisting, @@ -94,8 +97,23 @@ internal extension SessionPro.State { case .useActual: return SessionProUI.ClientPlatform(finalLatestPaymentItem?.paymentProvider) } }() - -// // TODO: [PRO] 'originatingAccount'?? I think we might need to check StoreKit transactions to see if they match the current one? (and if not then it's not the originating account?) + let finalEntitledTransactions: [Transaction] = entitledTransactions.or(self.entitledTransactions) + let finalOriginatingAccount: SessionPro.OriginatingAccount = { + switch dependencies[feature: .mockCurrentUserOriginatingAccount] { + case .simulate(let mockedValue): return mockedValue + case .useActual: + guard let lastPaymentItemAppleTransactionId: String = finalLatestPaymentItem?.appleTransactionId else { + return .nonOriginatingAccount + } + + let transactionIds: Set = Set(finalEntitledTransactions.map { "\($0.id)" }) + + return (transactionIds.contains(lastPaymentItemAppleTransactionId) ? + .originatingAccount : + .nonOriginatingAccount + ) + } + }() let finalRefundingStatus: SessionPro.RefundingStatus = { switch dependencies[feature: .mockCurrentUserSessionProRefundingStatus] { @@ -113,6 +131,7 @@ internal extension SessionPro.State { buildVariant: finalBuildVariant, products: products.or(self.products), plans: plans.or(self.plans), + entitledTransactions: finalEntitledTransactions, loadingState: finalLoadingState, status: finalStatus, proof: proof.or(self.proof), @@ -121,7 +140,7 @@ internal extension SessionPro.State { accessExpiryTimestampMs: finalAccessExpiryTimestampMs, latestPaymentItem: finalLatestPaymentItem, originatingPlatform: finalOriginatingPlatform, - originatingAccount: .originatingAccount, + originatingAccount: finalOriginatingAccount, refundingStatus: finalRefundingStatus ) } diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift index 8766c6c86e..ec5648f82d 100644 --- a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift +++ b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift @@ -10,16 +10,25 @@ public extension SessionProPaymentScreenContent.SessionProPlanPaymentFlow { let expiryDate: Date? = state.accessExpiryTimestampMs.map { Date(timeIntervalSince1970: floor(Double($0) / 1000)) } switch (state.status, latestPlan, state.refundingStatus) { - case (.neverBeenPro, _, _), (.active, .none, _): self = .purchase + case (.neverBeenPro, _, _), (.active, .none, _): + self = .purchase(billingAccess: state.buildVariant == .appStore) + case (.active, .some(let plan), .notRefunding): self = .update( currentPlan: SessionProPaymentScreenContent.SessionProPlanInfo(plan: plan), expiredOn: (expiryDate ?? Date.distantPast), originatingPlatform: state.originatingPlatform, - isAutoRenewing: (state.autoRenewing == true) + isAutoRenewing: (state.autoRenewing == true), + isNonOriginatingAccount: (state.originatingAccount == .nonOriginatingAccount), + billingAccess: (state.buildVariant == .appStore) + ) + + case (.expired, _, _): + self = .renew( + originatingPlatform: state.originatingPlatform, + billingAccess: (state.buildVariant == .appStore) ) - case (.expired, _, _): self = .renew(originatingPlatform: state.originatingPlatform) case (.active, .some, .refunding): self = .refund( originatingPlatform: state.originatingPlatform, diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index dd63519ee2..c1db27f4ef 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -100,7 +100,10 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has case .contact: /// If the thread is the Note to Self one then use the proper profile from the cache (instead of a random blinded one) guard !currentUserSessionIds.contains(thread.id) else { - return (dataCache.profile(for: dataCache.userSessionId.hexString) ?? Profile.defaultFor(dataCache.userSessionId.hexString)) + return ( + dataCache.profile(for: dataCache.userSessionId.hexString) ?? + Profile.defaultFor(dataCache.userSessionId.hexString) + ) } return (dataCache.profile(for: thread.id) ?? Profile.defaultFor(thread.id)) @@ -249,11 +252,9 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has switch thread.variant { case .contact: - // TODO: [PRO] Need to check if the pro status on the profile has expired - return ( - dataCache.profile(for: thread.id)?.proFeatures.contains(.proBadge) == true || - dependencies[feature: .proBadgeEverywhere] - ) + return dependencies[singleton: .sessionProManager] + .profileFeatures(for: profile) + .contains(.proBadge) case .group: return false // TODO: [PRO] Determine if the group is PRO case .community, .legacyGroup: return false @@ -318,17 +319,7 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has self.profile = profile.map { profile in profile.with( - proFeatures: .set(to: { - guard dependencies[feature: .sessionProEnabled] else { return .none } - // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? - var result: SessionPro.ProfileFeatures = profile.proFeatures - - if dependencies[feature: .proBadgeEverywhere] { - result.insert(.proBadge) - } - - return result - }()) + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: profile)) ) } self.additionalProfile = { @@ -342,17 +333,7 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has return dataCache.profile(for: targetId).map { profile in profile.with( - proFeatures: .set(to: { - guard dependencies[feature: .sessionProEnabled] else { return .none } - // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? - var result: SessionPro.ProfileFeatures = profile.proFeatures - - if dependencies[feature: .proBadgeEverywhere] { - result.insert(.proBadge) - } - - return result - }()) + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: profile)) ) } @@ -633,6 +614,7 @@ public extension ConversationInfoViewModel { public let isDestroyed: Bool public let adminProfile: Profile? public let currentUserRole: GroupMember.Role? + public let isProGroup: Bool init( group: ClosedGroup, @@ -654,6 +636,9 @@ public extension ConversationInfoViewModel { .map { $0.role } .sorted() .last /// We want the highest-ranking role (in case there are multiple entries) + + // TODO: [PRO] Need to determine whether it's a PRO group conversation + self.isProGroup = false } } } diff --git a/SessionMessagingKit/Types/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift index e4c0897d67..d977a742af 100644 --- a/SessionMessagingKit/Types/MessageViewModel.swift +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -264,7 +264,7 @@ public extension MessageViewModel { }() let proProfileFeatures: SessionPro.ProfileFeatures = { guard dependencies[feature: .sessionProEnabled] else { return .none } - // TODO: [PRO] Need to check if the pro status on the profile has expired + var result: SessionPro.ProfileFeatures = interaction.proProfileFeatures if dependencies[feature: .forceMessageFeatureProBadge] { @@ -307,17 +307,7 @@ public extension MessageViewModel { self.attachments = contentBuilder.attachments self.reactionInfo = (reactionInfo ?? []) self.profile = targetProfile.with( - proFeatures: .set(to: { - guard dependencies[feature: .sessionProEnabled] else { return .none } - // TODO: [PRO] Need to check if the pro status on the profile has expired - maybe add a function to SessionProManager to determine if the badge should show? - var result: SessionPro.ProfileFeatures = targetProfile.proFeatures - - if dependencies[feature: .proBadgeEverywhere] { - result.insert(.proBadge) - } - - return result - }()) + proFeatures: .set(to: dependencies[singleton: .sessionProManager].profileFeatures(for: targetProfile)) ) self.quoteViewModel = maybeUnresolvedQuotedInfo.map { info -> QuoteViewModel? in /// Should be `interaction` not `quotedInteraction` @@ -403,14 +393,9 @@ public extension MessageViewModel { ) } ), - showProBadge: { - guard dependencies[feature: .sessionProEnabled] else { return false } - // TODO: [PRO] Need to check if the pro status on the profile has expired - return ( - quotedAuthorProfile.proFeatures.contains(.proBadge) || - dependencies[feature: .proBadgeEverywhere] - ) - }(), + showProBadge: dependencies[singleton: .sessionProManager] + .profileFeatures(for: quotedAuthorProfile) + .contains(.proBadge), currentUserSessionIds: currentUserSessionIds, displayNameRetriever: dataCache.displayNameRetriever( for: threadInfo.id, @@ -704,25 +689,6 @@ public extension MessageViewModel { } } -// MARK: - TypingIndicatorInfo -// TODO: [PRO] Is this needed???? -public extension MessageViewModel { - struct TypingIndicatorInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { - case rowId - case threadId - } - - public let rowId: Int64 - public let threadId: String - - // MARK: - Identifiable - - public var id: String { threadId } - } -} - // MARK: - MaybeUnresolvedQuotedInfo public extension MessageViewModel { diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index f900658beb..95b856c51c 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -51,7 +51,7 @@ public extension ProfilePictureView.Info { let explicitPathFileExists: Bool = (explicitPath.map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) switch (explicitPath, explicitPathFileExists, publicKey.isEmpty, threadVariant) { - // TODO: Deal with this case later when implement group related Pro features + // TODO: [PRO] Deal with this case later when implement group related Pro features case (.some(let path), true, _, .legacyGroup), (.some(let path), true, _, .group): fallthrough case (.some(let path), true, _, .community): /// If we are given an explicit `displayPictureUrl` then only use that @@ -193,7 +193,8 @@ public extension ProfilePictureView.Info { } public extension ProfilePictureView { - // TODO: [PRO] Need to properly wire this up (it won't observe the changes, the parent screen will be responsible for updating the profile data and reloading the UI if the pro state changes) + /// This will made a decision based on the current state of the profile data, it's up to the parent screen to observer changes and trigger + /// a UI refresh to update this state static func canProfileAnimate(_ profile: Profile?, using dependencies: Dependencies) -> Bool { guard dependencies[feature: .sessionProEnabled] else { return true } @@ -203,7 +204,10 @@ public extension ProfilePictureView { case .some(let profile) where profile.id == dependencies[cache: .general].sessionId.hexString: return dependencies[singleton: .sessionProManager].currentUserIsCurrentlyPro - case .some(let profile): return (profile.proFeatures.contains(.animatedAvatar) == true) + case .some(let profile): + return dependencies[singleton: .sessionProManager] + .profileFeatures(for: profile) + .contains(.animatedAvatar) } } } diff --git a/SessionUIKit/Components/Input View/InputView.swift b/SessionUIKit/Components/Input View/InputView.swift index 7fa1513a49..157a06f363 100644 --- a/SessionUIKit/Components/Input View/InputView.swift +++ b/SessionUIKit/Components/Input View/InputView.swift @@ -302,8 +302,7 @@ public final class InputView: UIView, InputViewButtonDelegate, InputTextViewDele private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: .medium) - // TODO: [PRO] Need to add this back -// result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro + result.isHidden = (sessionProManager?.currentUserIsCurrentlyPro == true) return result }() diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift index 18048f786a..66eec6b660 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen+Models.swift @@ -1,6 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit public enum SessionProPaymentScreenContent {} @@ -162,10 +162,8 @@ public extension SessionProPaymentScreenContent { var errorString: String? { get set } var isFromBottomSheet: Bool { get } - @MainActor func purchase(planInfo: SessionProPlanInfo, success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) - @MainActor func cancelPro(success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) - @MainActor func requestRefund(success: (@MainActor () -> Void)?, failure: (@MainActor () -> Void)?) - func openURL(_ url: URL) + @MainActor func purchase(planInfo: SessionProPlanInfo) async throws + @MainActor func cancelPro(scene: UIWindowScene?) async throws + @MainActor func requestRefund(scene: UIWindowScene?) async throws } } - diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 77bca4fa28..8474773f29 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -109,7 +109,11 @@ public struct SessionProPaymentScreen: View { actionButtonTitle: "upgrade".localized(), actionType: "proUpgradingAction".localized(), activationType: "proActivatingActivation".localized(), - purchaseAction: { updatePlan() }, + purchaseAction: { + Task { @MainActor in + await updatePlan() + } + }, openTosPrivacyAction: { openTosPrivacy() } ) @@ -131,7 +135,11 @@ public struct SessionProPaymentScreen: View { actionButtonTitle: "renew".localized(), actionType: "proRenewingAction".localized(), activationType: "proReactivatingActivation".localized(), - purchaseAction: { updatePlan() }, + purchaseAction: { + Task { @MainActor in + await updatePlan() + } + }, openTosPrivacyAction: { openTosPrivacy() } ) @@ -167,7 +175,7 @@ public struct SessionProPaymentScreen: View { } ) - case .update(let currentPlan, let expiredOn, let originatingPlatform, let isAutoRenewing, _, billingAccess: true): + case .update(let currentPlan, _, _, _, _, billingAccess: true): SessionProPlanPurchaseContent( currentSelection: $currentSelection, isShowingTooltip: $isShowingTooltip, @@ -180,29 +188,34 @@ public struct SessionProPaymentScreen: View { .localized(), actionType: "proUpdatingAction".localized(), activationType: "", - purchaseAction: { updatePlan() }, + purchaseAction: { + Task { @MainActor in + await updatePlan() + } + }, openTosPrivacyAction: { openTosPrivacy() } ) - case .update(let currentPlan, let expiredOn, let originatingPlatform, let isAutoRenewing, _, billingAccess: false): + case .update(_, _, let originatingPlatform, _, _, billingAccess: false): NoBillingAccessContent( isRenewingPro: false, originatingPlatform: originatingPlatform, openProRoadmapAction: { openUrl(SNUIKit.urlStringProvider().proRoadmap) } ) - case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: false, let requestedAt), - .refund(originatingPlatform: .iOS, isNonOriginatingAccount: .none, let requestedAt): + case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: false, _), + .refund(originatingPlatform: .iOS, isNonOriginatingAccount: .none, _): RequestRefundOriginatingPlatformContent( requestRefundAction: { - viewModel.requestRefund( - success: { + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.requestRefund(scene: host.controller?.view.window?.windowScene) host.controller?.navigationController?.popViewController(animated: true) - }, - failure: { + } + catch { // TODO: [PRO] Request refund failure behaviour } - ) + } } ) @@ -229,14 +242,15 @@ public struct SessionProPaymentScreen: View { case .cancel(originatingPlatform: .iOS): CancelPlanOriginatingPlatformContent( cancelPlanAction: { - viewModel.cancelPro( - success: { + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.cancelPro(scene: host.controller?.view.window?.windowScene) host.controller?.navigationController?.popViewController(animated: true) - }, - failure: { + } + catch { // TODO: [PRO] Failed to cancel plan } - ) + } } ) @@ -251,18 +265,20 @@ public struct SessionProPaymentScreen: View { } } - private func updatePlan() { + private func updatePlan() async { let updatedPlan: SessionProPaymentScreenContent.SessionProPlanInfo = viewModel.dataModel.plans[currentSelection] isPendingPurchase = true switch viewModel.dataModel.flow { case .refund, .cancel: break case .purchase, .renew: - self.viewModel.purchase( - planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: nil) }, - failure: { onPaymentFailed() } - ) + do { + try await viewModel.purchase(planInfo: updatedPlan) + onPaymentSuccess(expiredOn: nil) + } + catch { + onPaymentFailed() + } case .update(let currentPlan, let expiredOn, _, let isAutoRenewing, _, _): let updatedPlanExpiredOn: Date = (Calendar.current @@ -292,12 +308,15 @@ public struct SessionProPaymentScreen: View { ), confirmTitle: "update".localized(), onConfirm: { _ in - self.viewModel.purchase( - planInfo: updatedPlan, - success: { onPaymentSuccess(expiredOn: updatedPlanExpiredOn) }, - failure: { onPaymentFailed() } - ) - + Task { @MainActor [weak viewModel] in + do { + try await viewModel?.purchase(planInfo: updatedPlan) + onPaymentSuccess(expiredOn: updatedPlanExpiredOn) + } + catch { + onPaymentFailed() + } + } } ) ) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift index 607a1f6b3c..b1445cb437 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProSettings+ProFeatures.swift @@ -88,10 +88,7 @@ public struct ProFeaturesInfo { .put(key: "pro", value: Constants.pro) .put(key: "icon", value: Lucide.Icon.squareArrowUpRight) .localizedFormatted(Fonts.Body.smallRegular), - accessory: .proBadgeLeading( - size: .mini, - themeBackgroundColor: (proState == .expired ? .disabled : .primary) - ) + accessory: .none ) } } diff --git a/SessionUtilitiesKit/Types/SessionProManagerType.swift b/SessionUtilitiesKit/Types/SessionProManagerType.swift deleted file mode 100644 index 14631ff86f..0000000000 --- a/SessionUtilitiesKit/Types/SessionProManagerType.swift +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine - -public protocol SessionProManagerType: AnyObject { - var sessionProStateSubject: CurrentValueSubject { get } - var sessionProStatePublisher: AnyPublisher { get } - var isSessionProActivePublisher: AnyPublisher { get } - var isSessionProExpired: Bool { get } - var sessionProPlans: [SessionProPlan] { get } - func upgradeToPro(plan: SessionProPlan, originatingPlatform: ClientPlatform, completion: ((_ result: Bool) -> Void)?) async - func cancelPro(completion: ((_ result: Bool) -> Void)?) async - func requestRefund(completion: ((_ result: Bool) -> Void)?) async - func expirePro(completion: ((_ result: Bool) -> Void)?) async - func recoverPro(completion: ((_ result: Bool) -> Void)?) async - // These functions are only for QA purpose - func updateOriginatingPlatform(_ newValue: ClientPlatform) - func updateProExpiry(_ expiryInSeconds: TimeInterval?) -} - -public enum SessionProPlanState: Equatable, Sendable { - case none - case active( - currentPlan: SessionProPlan, - expiredOn: Date, - isAutoRenewing: Bool, - originatingPlatform: ClientPlatform - ) - case expired( - expiredOn: Date, - originatingPlatform: ClientPlatform - ) - case refunding( - originatingPlatform: ClientPlatform, - requestedAt: Date? - ) - - public var originatingPlatform: ClientPlatform { - return switch(self) { - case .active(_, _, _, let originatingPlatform): originatingPlatform - case .expired(_, let originatingPlatform): originatingPlatform - case .refunding(let originatingPlatform, _): originatingPlatform - default: .iOS // FIXME: get the real originating platform - } - } - - public func with(originatingPlatform: ClientPlatform) -> SessionProPlanState { - switch self { - case .active(let plan, let expiredOn, let isAutoRenewing, _): - return .active( - currentPlan: plan, - expiredOn: expiredOn, - isAutoRenewing: isAutoRenewing, - originatingPlatform: originatingPlatform - ) - case .refunding(_, let requestedAt): - return .refunding( - originatingPlatform: originatingPlatform, - requestedAt: requestedAt - ) - case .expired(let expiredOn, _): - return .expired( - expiredOn: expiredOn, - originatingPlatform: originatingPlatform - ) - default: return self - } - } -} - -public struct SessionProPlan: Equatable, Sendable { - public enum Variant: Sendable { - case oneMonth, threeMonths, twelveMonths - - public static var allCases: [Variant] { [.twelveMonths, .threeMonths, .oneMonth] } - - public var duration: Int { - switch self { - case .oneMonth: return 1 - case .threeMonths: return 3 - case .twelveMonths: return 12 - } - } - - // MARK: - Mock - public var price: Double { - switch self { - case .oneMonth: return 5.99 - case .threeMonths: return 14.99 - case .twelveMonths: return 47.99 - } - } - - public var discountPercent: Int? { - switch self { - case .oneMonth: return nil - case .threeMonths: return 16 - case .twelveMonths: return 33 - } - } - } - - public let variant: Variant - - public init(variant: Variant) { - self.variant = variant - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.variant == rhs.variant - } -} - -// MARK: - Developer Settings - -public enum SessionProStateMock: String, Sendable, Codable, CaseIterable, FeatureOption { - case none - case active - case expiring - case expired - case refunding - - public static var defaultOption: SessionProStateMock = .none - - // stringlint:ignore_contents - public var title: String { - switch self { - case .none: return "None" - case .active: return "Active" - case .expiring: return "Expiring" - case .expired: return "Expired" - case .refunding: return "Refunding" - } - } - - // stringlint:ignore_contents - public var subtitle: String? { - switch self { - case .expiring: return "Active, no auto-renewing" - default: return nil - } - } -} - -extension ClientPlatform: FeatureOption { - public static var defaultOption: ClientPlatform = .iOS - public var title: String { deviceType } - public var subtitle: String? { return nil } -} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index def73cc432..a2e5539d1e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -694,10 +694,11 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } @MainActor func showModalForMessagesExceedingCharacterLimit() { - let didShowCTAModal: Bool = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .longerMessages(renew: (dependencies[singleton: .sessionProManager].currentUserCurrentProState.status == .expired)), - onConfirm: { [weak self] in - dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + let manager: SessionProManagerType = dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, @@ -748,10 +749,11 @@ extension AttachmentApprovalViewController: InputViewDelegate { public func cancelVoiceMessageRecording() {} public func handleCharacterLimitLabelTapped() { - let didShowCTAModal: Bool = dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded( - .longerMessages(renew: (dependencies[singleton: .sessionProManager].currentUserCurrentProState.status == .expired)), - onConfirm: { [weak self] in - dependencies[singleton: .sessionProManager].showSessionProBottomSheetIfNeeded( + let manager: SessionProManagerType = dependencies[singleton: .sessionProManager] + let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded( + .longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)), + onConfirm: { [weak self, manager] in + manager.showSessionProBottomSheetIfNeeded( afterClosed: { self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") }, From 089eccd52210b9ba6187e8d7b2ae2552f7c95a34 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 17 Dec 2025 13:17:34 +1100 Subject: [PATCH 39/66] Added some commented out code as reference --- SessionMessagingKit/SessionPro/SessionProManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 2967af91e6..46f836a255 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -778,6 +778,8 @@ public actor SessionProManager: SessionProManagerType { do { switch result { case .verified(let transaction): + // let transaction: Transaction = try result.payloadValue + // await transaction.finish() // TODO: [PRO] Need to actually handle this case (send to backend) break From 179e91b2e49e7efe3031686fa768dc7f21db5cc0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 17 Dec 2025 17:00:10 +1100 Subject: [PATCH 40/66] Fixed unit test build errors --- Session.xcodeproj/project.pbxproj | 40 +- .../ConversationVC+Interaction.swift | 2 +- .../LibSession+SessionMessagingKit.swift | 2 +- .../VisibleMessage+Profile.swift | 2 +- .../Open Groups/CommunityManager.swift | 8 +- .../Crypto/CryptoSMKSpec.swift | 75 +- .../Database/Models/GroupMemberSpec.swift | 20 +- .../Models/MessageDeduplicationSpec.swift | 24 +- .../Jobs/DisplayPictureDownloadJobSpec.swift | 215 +--- .../Jobs/MessageSendJobSpec.swift | 3 +- ...RetrieveDefaultOpenGroupRoomsJobSpec.swift | 66 +- .../LibSession/LibSessionGroupInfoSpec.swift | 84 +- .../LibSession/LibSessionSpec.swift | 30 +- ...rSpec.swift => CommunityManagerSpec.swift} | 926 +++++++++--------- .../Crypto/CryptoOpenGroupSpec.swift | 76 -- .../Open Groups/Models/OpenGroupSpec.swift | 8 +- .../MessageReceiverGroupsSpec.swift | 248 ++++- .../MessageSenderGroupsSpec.swift | 25 +- .../MessageSenderSpec.swift | 12 +- .../NotificationsManagerSpec.swift | 6 +- .../Pollers/CommunityPollerSpec.swift | 14 +- .../GlobalSearchSpec.swift} | 46 +- .../Utilities/ExtensionHelperSpec.swift | 30 +- .../CommonSMKMockExtensions.swift | 37 +- .../_TestUtilities/MockCommunityManager.swift | 180 ++++ .../MockDisplayPictureCache.swift | 2 +- .../_TestUtilities/MockLibSessionCache.swift | 39 +- .../_TestUtilities/MockOGMCache.swift | 30 - .../SOGS/SOGSAPISpec.swift | 2 +- ...eadDisappearingMessagesViewModelSpec.swift | 9 +- .../ThreadSettingsViewModelSpec.swift | 268 ++++- SessionTests/Onboarding/OnboardingSpec.swift | 49 +- .../General/GeneralCacheSpec.swift | 8 +- _SharedTestUtilities/Mocked.swift | 1 + 34 files changed, 1538 insertions(+), 1049 deletions(-) rename SessionMessagingKitTests/Open Groups/{OpenGroupManagerSpec.swift => CommunityManagerSpec.swift} (80%) rename SessionMessagingKitTests/{Shared Models/SessionThreadViewModelSpec.swift => Types/GlobalSearchSpec.swift} (87%) create mode 100644 SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift delete mode 100644 SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 32fb9fb325..a9044ff703 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -455,7 +455,7 @@ FD05594E2E012D2700DC48CE /* _043_RenameAttachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */; }; FD0559562E026E1B00DC48CE /* ObservingDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0559542E026CC900DC48CE /* ObservingDatabase.swift */; }; FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */; }; - FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */; }; + FD078E5427E197CA000769AF /* CommunityManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */; }; FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0969F82A69FFE700C5C365 /* Mocked.swift */; }; @@ -648,7 +648,7 @@ FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */; }; FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */; }; - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */; }; + FD336F632CAA28CF00C0B51B /* MockCommunityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */; }; FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */; }; FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */; }; FD336F662CAA28CF00C0B51B /* MockSwarmPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */; }; @@ -931,6 +931,7 @@ FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD71B9B02EF25A1200379A99 /* GlobalSearchSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */; }; FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */; }; FD72BDA42BE3690B00CF6CF6 /* CryptoSMKSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */; }; FD72BDA72BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */; }; @@ -944,7 +945,6 @@ FD756BEB2D0181D700BD7199 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BEA2D0181D700BD7199 /* GRDB */; }; FD756BF02D06686500BD7199 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BEF2D06686500BD7199 /* Lucide */; }; FD756BF22D06687800BD7199 /* Lucide in Frameworks */ = {isa = PBXBuildFile; productRef = FD756BF12D06687800BD7199 /* Lucide */; }; - FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */; }; FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; @@ -2149,7 +2149,7 @@ FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGroupPollerCache.swift; sourceTree = ""; }; FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLibSessionCache.swift; sourceTree = ""; }; FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNotificationsManager.swift; sourceTree = ""; }; - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOGMCache.swift; sourceTree = ""; }; + FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCommunityManager.swift; sourceTree = ""; }; FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPoller.swift; sourceTree = ""; }; FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSwarmPoller.swift; sourceTree = ""; }; FD336F6B2CAA29C200C0B51B /* CommunityPollerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityPollerSpec.swift; sourceTree = ""; }; @@ -2324,6 +2324,7 @@ FD716E6528502EE200C96BF4 /* CurrentCallProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentCallProtocol.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; + FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchSpec.swift; sourceTree = ""; }; FD72BDA02BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindowLevel+Utilities.swift"; sourceTree = ""; }; FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoSMKSpec.swift; sourceTree = ""; }; FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoOpenGroupSpec.swift; sourceTree = ""; }; @@ -2334,7 +2335,6 @@ FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; FD7443482D07CA9F00862443 /* CGSize+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Utilities.swift"; sourceTree = ""; }; FD7443492D07CA9F00862443 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; - FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _028_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; @@ -2495,7 +2495,7 @@ FDC2909327D710B4005DAE71 /* SOGSEndpointSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSEndpointSpec.swift; sourceTree = ""; }; FDC2909527D71252005DAE71 /* SOGSErrorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSErrorSpec.swift; sourceTree = ""; }; FDC2909727D7129B005DAE71 /* PersonalizationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalizationSpec.swift; sourceTree = ""; }; - FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManagerSpec.swift; sourceTree = ""; }; + FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityManagerSpec.swift; sourceTree = ""; }; FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleExtensions.swift; sourceTree = ""; }; FDC4380827B31D4E00C60D73 /* SOGSError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSError.swift; sourceTree = ""; }; FDC4381627B32EC700C60D73 /* Personalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Personalization.swift; sourceTree = ""; }; @@ -5050,28 +5050,28 @@ path = Views; sourceTree = ""; }; - FD72BDA22BE368FA00CF6CF6 /* Crypto */ = { + FD71B9AE2EF251AF00379A99 /* Types */ = { isa = PBXGroup; children = ( - FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */, + FD71B9AF2EF25A0E00379A99 /* GlobalSearchSpec.swift */, ); - path = Crypto; + path = Types; sourceTree = ""; }; - FD72BDA52BE369B600CF6CF6 /* Crypto */ = { + FD72BDA22BE368FA00CF6CF6 /* Crypto */ = { isa = PBXGroup; children = ( - FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */, + FD72BDA32BE3690B00CF6CF6 /* CryptoSMKSpec.swift */, ); path = Crypto; sourceTree = ""; }; - FD7692F52A53A2C7000E4B70 /* Shared Models */ = { + FD72BDA52BE369B600CF6CF6 /* Crypto */ = { isa = PBXGroup; children = ( - FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */, + FD72BDA62BE369DC00CF6CF6 /* CryptoOpenGroupSpec.swift */, ); - path = "Shared Models"; + path = Crypto; sourceTree = ""; }; FD7728A1284F0DF50018502F /* Message Handling */ = { @@ -5472,8 +5472,8 @@ FD96F3A229DBC3BA00401309 /* Jobs */, FDC4389827BA001800C60D73 /* Open Groups */, FDE754A72C9B964D002A2623 /* Sending & Receiving */, - FD7692F52A53A2C7000E4B70 /* Shared Models */, FD8ECF802934385900C0D1BB /* LibSession */, + FD71B9AE2EF251AF00379A99 /* Types */, FD981BC72DC4640100564172 /* Utilities */, ); path = SessionMessagingKitTests; @@ -5484,7 +5484,7 @@ children = ( FD72BDA52BE369B600CF6CF6 /* Crypto */, FD83B9C127CF33EE005E1583 /* Models */, - FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */, + FDC2909D27D85751005DAE71 /* CommunityManagerSpec.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -5494,6 +5494,7 @@ children = ( FD336F562CAA28CF00C0B51B /* CommonSMKMockExtensions.swift */, FD336F572CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift */, + FD336F5D2CAA28CF00C0B51B /* MockCommunityManager.swift */, FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */, FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */, FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */, @@ -5502,7 +5503,6 @@ FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */, FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */, FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */, - FD336F5D2CAA28CF00C0B51B /* MockOGMCache.swift */, FD336F5E2CAA28CF00C0B51B /* MockPoller.swift */, FD336F5F2CAA28CF00C0B51B /* MockSwarmPoller.swift */, ); @@ -7901,8 +7901,9 @@ FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */, FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */, FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */, + FD71B9B02EF25A1200379A99 /* GlobalSearchSpec.swift in Sources */, FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, - FD336F632CAA28CF00C0B51B /* MockOGMCache.swift in Sources */, + FD336F632CAA28CF00C0B51B /* MockCommunityManager.swift in Sources */, FD481A922CAD17DE00ECC4CF /* LibSessionGroupMembersSpec.swift in Sources */, FD336F642CAA28CF00C0B51B /* MockCommunityPollerCache.swift in Sources */, FD336F652CAA28CF00C0B51B /* MockDisplayPictureCache.swift in Sources */, @@ -7923,7 +7924,6 @@ FD23CE342A67C4D90000B97C /* MockNetwork.swift in Sources */, FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, - FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, FD0969F92A69FFE700C5C365 /* Mocked.swift in Sources */, FD481A902CAD16F100ECC4CF /* LibSessionGroupInfoSpec.swift in Sources */, FDE754A92C9B964D002A2623 /* MessageSenderGroupsSpec.swift in Sources */, @@ -7935,7 +7935,7 @@ FD01502A2CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FD01503B2CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */, - FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, + FD078E5427E197CA000769AF /* CommunityManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index e407d952b4..761a629aab 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1211,7 +1211,7 @@ extension ConversationVC: switch messageInfo.state { case .permissionDenied: let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( - caller: cellViewModel.authorName, + caller: cellViewModel.authorName(), presentingViewController: self, using: viewModel.dependencies ) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 68400581a0..838d6062b5 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -543,7 +543,7 @@ public extension LibSession { /// Count the OneToOne conversations (visible contacts) if case .contacts(let conf) = configStore[userSessionId, .contacts], - let contactData: [String: ContactData] = try? LibSession.extractContacts(from: conf, using: dependencies) + let contactData: [String: ContactData] = try? extractContacts(from: conf) { let visibleContacts: [ContactData] = contactData.values .filter { $0.priority >= LibSession.visiblePriority } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 307062a313..174f5212b5 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -15,7 +15,7 @@ public extension VisibleMessage { // MARK: - Initialization - private init( + internal init( displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil, diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index c59174619b..1154c5b5c8 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -1228,7 +1228,7 @@ public actor CommunityManager: CommunityManagerType { /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group public func isUserModeratorOrAdmin( - publicKey: String, + targetUserPublicKey: String, server maybeServer: String?, roomToken: String?, includingHidden: Bool @@ -1241,9 +1241,9 @@ public actor CommunityManager: CommunityManagerType { else { return false } /// If the `publicKey` belongs to the current user then we should check against any of their pubkey possibilities - let possibleKeys: Set = (cachedServer.currentUserSessionIds.contains(publicKey) ? + let possibleKeys: Set = (cachedServer.currentUserSessionIds.contains(targetUserPublicKey) ? cachedServer.currentUserSessionIds : - [publicKey] + [targetUserPublicKey] ) /// Check if the `publicKey` matches a visible admin or moderator @@ -1430,7 +1430,7 @@ public protocol CommunityManagerType { includingHidden: Bool ) async -> Set func isUserModeratorOrAdmin( - publicKey: String, + targetUserPublicKey: String, server maybeServer: String?, roomToken: String?, includingHidden: Bool diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index c6274f4984..6bf9aa3a5f 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -66,14 +66,19 @@ class CryptoSMKSpec: QuickSpec { } } - // MARK: -- when encrypting with the session protocol - context("when encrypting with the session protocol") { + // MARK: -- when encoding messages + context("when encoding messages") { + @TestState var result: Data? + // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionProtocol( + result = try? crypto.tryGenerate( + .encodedMessage( plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)") + proMessageFeatures: .none, + proProfileFeatures: .none, + destination: .contact(publicKey: "05\(TestConstants.publicKey)"), + sentTimestampMs: 1234567890 ) ) @@ -87,10 +92,13 @@ class CryptoSMKSpec: QuickSpec { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( + result = try crypto.tryGenerate( + .encodedMessage( plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)") + proMessageFeatures: .none, + proProfileFeatures: .none, + destination: .contact(publicKey: "05\(TestConstants.publicKey)"), + sentTimestampMs: 1234567890 ) ) } @@ -100,20 +108,29 @@ class CryptoSMKSpec: QuickSpec { // MARK: -- when decrypting with the session protocol context("when decrypting with the session protocol") { + @TestState var result: DecodedMessage? + // MARK: ---- successfully decrypts a message it("successfully decrypts a message") { - let result = crypto.generate( - .plaintextWithSessionProtocol( - ciphertext: Data( + result = try? crypto.generate( + .decodedMessage( + encodedMessage: Data( base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )! + )!, + origin: .swarm( + publicKey: TestConstants.publicKey, + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) - - expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) - expect(result?.senderSessionIdHex) + + expect(String(data: (result?.content ?? Data()), encoding: .utf8)).to(equal("TestMessage")) + expect(result?.sender.hexString) .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } @@ -122,13 +139,20 @@ class CryptoSMKSpec: QuickSpec { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - ciphertext: Data( + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: Data( base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )! + )!, + origin: .swarm( + publicKey: TestConstants.publicKey, + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) } @@ -138,9 +162,16 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if the ciphertext is too short it("throws an error if the ciphertext is too short") { expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - ciphertext: Data([1, 2, 3]) + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: Data([1, 2, 3]), + origin: .swarm( + publicKey: TestConstants.publicKey, + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) } diff --git a/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift b/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift index 93215ea73c..28fab154e0 100644 --- a/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift +++ b/SessionMessagingKitTests/Database/Models/GroupMemberSpec.swift @@ -27,7 +27,15 @@ class GroupMemberSpec: QuickSpec { ), profile: Profile( id: "05_(Id\(index < 10 ? "0" : "")\(index))", - name: "Name\(index < 10 ? "0" : "")\(index)" + name: "Name\(index < 10 ? "0" : "")\(index)", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ), currentUserSessionId: userSessionId ) @@ -276,7 +284,15 @@ private extension Array where Element == WithProfile { current.profile.map { currentProfile in Profile( id: (profileId ?? current.profileId), - name: (name ?? currentProfile.name) + name: (name ?? currentProfile.name), + nickname: currentProfile.nickname, + displayPictureUrl: currentProfile.displayPictureUrl, + displayPictureEncryptionKey: currentProfile.displayPictureEncryptionKey, + profileLastUpdated: currentProfile.profileLastUpdated, + blocksCommunityMessageRequests: currentProfile.blocksCommunityMessageRequests, + proFeatures: currentProfile.proFeatures, + proExpiryUnixTimestampMs: currentProfile.proExpiryUnixTimestampMs, + proGenIndexHashHex: currentProfile.proGenIndexHashHex ) } ), diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift index 4849b49d8d..75f840f399 100644 --- a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -294,13 +294,17 @@ class MessageDeduplicationSpec: AsyncSpec { processedMessage: .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: mockMessage, variant: .readReceipt, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), @@ -806,13 +810,17 @@ class MessageDeduplicationSpec: AsyncSpec { .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: Message(), variant: .visibleMessage, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), @@ -1041,13 +1049,17 @@ class MessageDeduplicationSpec: AsyncSpec { .standard( threadId: "testThreadId", threadVariant: .contact, - proto: try! SNProtoContent.builder().build(), messageInfo: MessageReceiveJob.Details.MessageInfo( message: Message(), variant: .visibleMessage, threadVariant: .contact, serverExpirationTimestamp: nil, - proto: try! SNProtoContent.builder().build() + decodedMessage: DecodedMessage( + content: Data(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) ), uniqueIdentifier: "testId" ), diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 705ce13d6d..b4090bbdfb 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -196,57 +196,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given a null url - it("returns nil when given a null url") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: nil, - displayPictureEncryptionKey: encryptionKey - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns nil when given a null encryption key - it("returns nil when given a null encryption key") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .user( - Profile( - id: "1234", - name: "test", - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: encryptionKey - ) - ) - ) - ).toNot(beNil()) - } - } } // MARK: ---- for a group @@ -307,66 +256,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given a null url - it("returns nil when given a null url") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: nil, - displayPictureEncryptionKey: encryptionKey, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns nil when given a null encryption key - it("returns nil when given a null encryption key") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: nil, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .group( - ClosedGroup( - threadId: "1234", - name: "test", - formationTimestamp: 0, - displayPictureUrl: "http://oxen.io/1234/", - displayPictureEncryptionKey: encryptionKey, - shouldPoll: nil, - invited: nil - ) - ) - ) - ).toNot(beNil()) - } - } } // MARK: ---- for a community @@ -393,49 +282,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ).toNot(beNil()) } } - - // MARK: ------ with an owner - context("with an owner") { - // MARK: -------- returns nil when given an empty imageId - it("returns nil when given an empty imageId") { - expect( - DisplayPictureDownloadJob.Details( - owner: .community( - OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: "1234", - isActive: false, - name: "test", - imageId: nil, - userCount: 0, - infoUpdates: 0 - ) - ) - ) - ).to(beNil()) - } - - // MARK: -------- returns a value when given valid data - it("returns a value when given valid data") { - expect( - DisplayPictureDownloadJob.Details( - owner: .community( - OpenGroup( - server: "testServer", - roomToken: "testRoom", - publicKey: "1234", - isActive: false, - name: "test", - imageId: "12", - userCount: 0, - infoUpdates: 0 - ) - ) - ) - ).toNot(beNil()) - } - } } } @@ -492,9 +338,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -546,7 +397,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: false, + shouldPoll: false, name: "test", imageId: "12", userCount: 0, @@ -570,7 +421,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { try Network.SOGS.preparedDownload( fileId: "12", roomToken: "testRoom", - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: "testserver", @@ -614,9 +465,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -734,9 +590,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { profile = Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567890 + profileLastUpdated: 1234567890, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) mockStorage.write { db in _ = try Profile.deleteAll(db) @@ -808,9 +669,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -845,9 +711,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -890,9 +761,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -905,9 +781,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { Profile( id: "1234", name: "test", + nickname: nil, displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - profileLastUpdated: 1234567891 + profileLastUpdated: 1234567891, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) } @@ -1086,7 +967,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1172,7 +1053,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1215,7 +1096,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, @@ -1234,7 +1115,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: "03cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece", - isActive: true, + shouldPoll: true, name: "name", imageId: "100", userCount: 1, diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 693a81bc63..c48072f91f 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -187,7 +187,8 @@ class MessageSendJobSpec: AsyncSpec { state: .sending, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ) job = Job( variant: .messageSend, diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 24b8185e74..46b44dca6e 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -91,9 +91,11 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .thenReturn([:]) } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager + .when { await $0.updateRooms(rooms: .any, server: .any, publicKey: .any, areDefaultRooms: .any) } + .thenReturn(()) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -215,7 +217,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) expect(openGroups?.map { $0.roomToken }).to(equal([""])) expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false])) + expect(openGroups?.map { $0.shouldPoll }).to(equal([false])) expect(openGroups?.map { $0.name }).to(equal([""])) } @@ -238,7 +240,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", userCount: 0, infoUpdates: 0 @@ -260,7 +262,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) expect(openGroups?.map { $0.roomToken }).to(equal([""])) expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false])) + expect(openGroups?.map { $0.shouldPoll }).to(equal([false])) expect(openGroups?.map { $0.name }).to(equal(["TestExisting"])) } @@ -271,7 +273,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", userCount: 0, infoUpdates: 0 @@ -280,7 +282,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: Network.SOGS.defaultServer, @@ -396,7 +398,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey ])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false, false])) + expect(openGroups?.map { $0.shouldPoll }).to(equal([false, false, false])) expect(openGroups?.map { $0.name }).to(equal(["", "TestRoomName", "TestRoomName2"])) } @@ -407,7 +409,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "testRoom", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", userCount: 0, infoUpdates: 0 @@ -464,7 +466,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(openGroups?.map { $0.roomToken }.sorted()).to(equal(["", "testRoom"])) expect(openGroups?.map { $0.publicKey }) .to(equal([Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.isActive }).to(equal([false, false])) + expect(openGroups?.map { $0.shouldPoll }).to(equal([false, false])) expect(openGroups?.map { $0.name }.sorted()).to(equal(["", "TestExisting"])) } @@ -509,7 +511,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "testRoom2", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", imageId: "10", userCount: 0, @@ -611,7 +613,7 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "testRoom2", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", imageId: "12", userCount: 0, @@ -645,43 +647,25 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockOGMCache) + expect(mockCommunityManager) .toNot(call(matchingParameters: .all) { - $0.setDefaultRoomInfo([ - ( - room: Network.SOGS.Room.mock.with( + await $0.updateRooms( + rooms: [ + Network.SOGS.Room.mock.with( token: "testRoom", name: "TestRoomName" ), - openGroup: OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestRoomName", - userCount: 0, - infoUpdates: 0 - ) - ), - ( - room: Network.SOGS.Room.mock.with( + Network.SOGS.Room.mock.with( token: "testRoom2", name: "TestRoomName2", infoUpdates: 12, imageId: "12" - ), - openGroup: OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, - name: "TestRoomName2", - imageId: "12", - userCount: 0, - infoUpdates: 12 ) - ) - ]) + ], + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) }) } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 180d585389..df91cba449 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -134,8 +134,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -153,8 +152,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupMembers]!, - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } .to(throwError()) @@ -169,8 +167,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -192,8 +189,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -215,8 +211,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -242,8 +237,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -271,8 +265,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -296,8 +289,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -337,8 +329,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -389,7 +380,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -399,8 +391,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -445,7 +436,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -468,7 +460,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -478,8 +471,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -529,7 +521,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -552,8 +545,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -598,7 +590,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -621,8 +614,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -679,7 +671,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) let interaction2: Interaction = try Interaction( serverHash: "1235", @@ -702,7 +695,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -736,8 +730,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -782,7 +775,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( serverHash: "1235", @@ -805,7 +799,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Attachment( id: "AttachmentId", @@ -828,8 +823,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -875,7 +869,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -885,8 +880,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } @@ -945,7 +939,8 @@ class LibSessionGroupInfoSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } @@ -955,8 +950,7 @@ class LibSessionGroupInfoSpec: QuickSpec { try mockLibSessionCache.handleGroupInfoUpdate( db, in: createGroupOutput.groupState[.groupInfo], - groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId), - serverTimestampMs: 1234567891000 + groupSessionId: SessionId(.group, hex: createGroupOutput.group.threadId) ) } diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index cf6b601349..19d9f6cce4 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -50,7 +50,7 @@ class LibSessionSpec: QuickSpec { ) ) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: "cbd569f56fb13ea95a3f0c05c331cc24139c0090feb412069dc49fab34406ece")), @@ -382,7 +382,15 @@ class LibSessionSpec: QuickSpec { id: "123456", profile: Profile( id: "123456", - name: "" + name: "", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies @@ -460,8 +468,14 @@ class LibSessionSpec: QuickSpec { profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", name: "TestName", + nickname: nil, displayPictureUrl: "testUrl", - displayPictureEncryptionKey: Data([1, 2, 3]) + displayPictureEncryptionKey: Data([1, 2, 3]), + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies @@ -504,7 +518,15 @@ class LibSessionSpec: QuickSpec { id: "051111111111111111111111111111111111111111111111111111111111111111", profile: Profile( id: "051111111111111111111111111111111111111111111111111111111111111111", - name: "TestName" + name: "TestName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )], using: dependencies diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift similarity index 80% rename from SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift rename to SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift index 6fed99f9ed..f7b82dfbbe 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift @@ -12,7 +12,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionNetworkingKit -class OpenGroupManagerSpec: QuickSpec { +class CommunityManagerSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -42,7 +42,8 @@ class OpenGroupManagerSpec: QuickSpec { state: .sending, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ) @TestState var testGroupThread: SessionThread! = SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), @@ -53,7 +54,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", roomDescription: nil, imageId: nil, @@ -183,7 +184,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -219,12 +220,16 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( initialSetup: { $0.defaultInitialSetup() } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.pendingChanges = .any }.thenReturn(()) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) - cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager.when { await $0.pendingChanges }.thenReturn([]) + manager.when { await $0.setPendingChanges(.any) }.thenReturn(()) + manager.when { await $0.updatePendingChange(.any, seqNo: .any) }.thenReturn(()) + manager.when { await $0.removePendingChange(.any) }.thenReturn(()) + manager.when { await $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) + manager + .when { await $0.updateRooms(rooms: .any, server: .any, publicKey: .any, areDefaultRooms: .any) } + .thenReturn(()) } ) @TestState var mockPoller: MockCommunityPoller! = MockCommunityPoller( @@ -268,8 +273,7 @@ class OpenGroupManagerSpec: QuickSpec { }() @TestState var disposables: [AnyCancellable]! = [] - @TestState var cache: OpenGroupManager.Cache! = OpenGroupManager.Cache(using: dependencies) - @TestState var openGroupManager: OpenGroupManager! = OpenGroupManager(using: dependencies) + @TestState var communityManager: CommunityManager! = CommunityManager(using: dependencies) // MARK: - an OpenGroupManager describe("an OpenGroupManager") { @@ -287,7 +291,9 @@ class OpenGroupManagerSpec: QuickSpec { } .thenReturn(nil) - expect(cache.getLastSuccessfulCommunityPollTimestamp()).to(equal(0)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(0)) } // MARK: ---- returns the time since the last poll @@ -299,8 +305,9 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567880)) dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567880)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567880)) } // MARK: ---- caches the time since the last poll in memory @@ -312,8 +319,9 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567770)) dependencies.dateNow = Date(timeIntervalSince1970: 1234567780) - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567770)) mockUserDefaults .when { (defaults: inout any UserDefaultsType) -> Any? in @@ -322,13 +330,14 @@ class OpenGroupManagerSpec: QuickSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) // Cached value shouldn't have been updated - expect(cache.getLastSuccessfulCommunityPollTimestamp()) - .to(equal(1234567770)) + await expect { + await communityManager.getLastSuccessfulCommunityPollTimestamp() + }.toEventually(equal(1234567770)) } // MARK: ---- updates the time since the last poll in user defaults it("updates the time since the last poll in user defaults") { - cache.setLastSuccessfulCommunityPollTimestamp(12345) + await communityManager.setLastSuccessfulCommunityPollTimestamp(12345) expect(mockUserDefaults) .to(call(matchingParameters: .all) { @@ -344,67 +353,67 @@ class OpenGroupManagerSpec: QuickSpec { context("when checking if an open group is run by session") { // MARK: ---- returns false when it does not match one of Sessions servers with no scheme it("returns false when it does not match one of Sessions servers with no scheme") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "test.test")) .to(beFalse()) } // MARK: ---- returns false when it does not match one of Sessions servers in http it("returns false when it does not match one of Sessions servers in http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "http://test.test")) .to(beFalse()) } // MARK: ---- returns false when it does not match one of Sessions servers in https it("returns false when it does not match one of Sessions servers in https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://test.test")) + expect(CommunityManager.isSessionRunCommunity(server: "https://test.test")) .to(beFalse()) } // MARK: ---- returns true when it matches Sessions SOGS IP it("returns true when it matches Sessions SOGS IP") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with http it("returns true when it matches Sessions SOGS IP with http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "http://116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with https it("returns true when it matches Sessions SOGS IP with https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://116.203.70.33")) + expect(CommunityManager.isSessionRunCommunity(server: "https://116.203.70.33")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS IP with a port it("returns true when it matches Sessions SOGS IP with a port") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "116.203.70.33:80")) + expect(CommunityManager.isSessionRunCommunity(server: "116.203.70.33:80")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain it("returns true when it matches Sessions SOGS domain") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with http it("returns true when it matches Sessions SOGS domain with http") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "http://open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "http://open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with https it("returns true when it matches Sessions SOGS domain with https") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "https://open.getsession.org")) + expect(CommunityManager.isSessionRunCommunity(server: "https://open.getsession.org")) .to(beTrue()) } // MARK: ---- returns true when it matches Sessions SOGS domain with a port it("returns true when it matches Sessions SOGS domain with a port") { - expect(OpenGroupManager.isSessionRunOpenGroup(server: "open.getsession.org:80")) + expect(CommunityManager.isSessionRunCommunity(server: "open.getsession.org:80")) .to(beTrue()) } } @@ -422,42 +431,33 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: -------- returns true when no scheme is provided it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } @@ -467,42 +467,33 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: -------- returns true when no scheme is provided it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } @@ -512,42 +503,33 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: -------- returns true when no scheme is provided it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a http scheme is provided it("returns true when a http scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } // MARK: -------- returns true when a https scheme is provided it("returns true when a https scheme is provided") { expect( - mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } @@ -574,8 +556,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, + communityManager.hasExistingCommunity( roomToken: "testRoom", server: "http://open.getsession.org", publicKey: TestConstants.serverPublicKey @@ -606,8 +587,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, + communityManager.hasExistingCommunity( roomToken: "testRoom", server: "http://116.203.70.33", publicKey: TestConstants.serverPublicKey @@ -621,8 +601,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns false when given an invalid server") { expect( mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, + communityManager.hasExistingCommunity( roomToken: "testRoom", server: "%%%", publicKey: TestConstants.serverPublicKey @@ -637,8 +616,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, + communityManager.hasExistingCommunity( roomToken: "testRoom", server: "http://127.0.0.1", publicKey: TestConstants.serverPublicKey @@ -655,8 +633,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db -> Bool in - openGroupManager.hasExistingOpenGroup( - db, + communityManager.hasExistingCommunity( roomToken: "testRoom", server: "http://127.0.0.1", publicKey: TestConstants.serverPublicKey @@ -699,7 +676,7 @@ class OpenGroupManagerSpec: QuickSpec { it("stores the open group server") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -709,7 +686,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -734,7 +711,7 @@ class OpenGroupManagerSpec: QuickSpec { it("adds a poller") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -744,7 +721,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -779,7 +756,7 @@ class OpenGroupManagerSpec: QuickSpec { it("does not reset the sequence number or update the public key") { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -791,7 +768,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -850,7 +827,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage .writePublisher { db -> Bool in - openGroupManager.add( + communityManager.add( db, roomToken: "testRoom", server: "http://127.0.0.1", @@ -860,7 +837,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .flatMap { successfullyAddedGroup in - openGroupManager.performInitialRequestsAfterAdd( + communityManager.performInitialRequestsAfterAdd( queue: DispatchQueue.main, successfullyAddedGroup: successfullyAddedGroup, roomToken: "testRoom", @@ -898,7 +875,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- removes all interactions for the thread it("removes all interactions for the thread") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -912,7 +889,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- removes the given thread it("removes the given thread") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -928,7 +905,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ stops the poller it("stops the poller") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -942,7 +919,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ removes the open group it("removes the open group") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -964,7 +941,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom1", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -980,7 +957,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ removes the open group it("removes the open group") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), skipLibSessionUpdate: true @@ -1001,7 +978,7 @@ class OpenGroupManagerSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -1015,7 +992,7 @@ class OpenGroupManagerSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "testRoom1", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -1031,7 +1008,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does not remove the open group it("does not remove the open group") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), skipLibSessionUpdate: true @@ -1045,7 +1022,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ deactivates the open group it("deactivates the open group") { mockStorage.write { db in - try openGroupManager.delete( + try communityManager.delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), skipLibSessionUpdate: true @@ -1055,7 +1032,7 @@ class OpenGroupManagerSpec: QuickSpec { expect( mockStorage.read { db in try OpenGroup - .select(.isActive) + .select(.shouldPoll) .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer)) .asRequest(of: Bool.self) .fetchOne(db) @@ -1069,15 +1046,15 @@ class OpenGroupManagerSpec: QuickSpec { context("when handling capabilities") { beforeEach { mockStorage.write { db in - OpenGroupManager - .handleCapabilities( - db, - capabilities: Network.SOGS.CapabilitiesResponse( - capabilities: ["sogs"], - missing: [] - ), - on: "http://127.0.0.1" - ) + communityManager.handleCapabilities( + db, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: ["sogs"], + missing: [] + ), + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey + ) } } @@ -1104,13 +1081,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- saves the updated open group it("saves the updated open group") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1127,13 +1103,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not schedule the displayPictureDownload job if there is no image it("does not schedule the displayPictureDownload job if there is no image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1167,7 +1142,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1176,13 +1151,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1224,13 +1198,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1271,13 +1244,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1312,13 +1284,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1343,13 +1314,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1390,13 +1360,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1432,13 +1401,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1456,13 +1424,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1470,32 +1437,6 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- when not given a public key - context("when not given a public key") { - // MARK: ------ saves the open group with the existing public key - it("saves the open group with the existing public key") { - mockStorage.write { db in - try OpenGroupManager.handlePollInfo( - db, - pollInfo: testPollInfo, - publicKey: nil, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies - ) - } - - expect( - mockStorage.read { db -> String? in - try OpenGroup - .select(.publicKey) - .asRequest(of: String.self) - .fetchOne(db) - } - ).to(equal(TestConstants.publicKey)) - } - } - // MARK: ---- when trying to get the room image context("when trying to get the room image") { beforeEach { @@ -1518,13 +1459,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1566,7 +1506,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1582,13 +1522,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1619,7 +1558,7 @@ class OpenGroupManagerSpec: QuickSpec { server: "http://127.0.0.1", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", imageId: "12", userCount: 0, @@ -1640,13 +1579,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1691,13 +1629,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does nothing if there is no room image it("does nothing if there is no room image") { mockStorage.write { db in - try OpenGroupManager.handlePollInfo( + try communityManager.handlePollInfo( db, pollInfo: testPollInfo, - publicKey: TestConstants.publicKey, - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + publicKey: TestConstants.publicKey ) } @@ -1729,7 +1666,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- updates the sequence number when there are messages it("updates the sequence number when there are messages") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1747,9 +1684,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1766,12 +1703,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not update the sequence number if there are no messages it("does not update the sequence number if there are no messages") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1792,7 +1729,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1810,9 +1747,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1826,7 +1763,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1844,9 +1781,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1856,12 +1793,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [testMessage], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1871,7 +1808,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1890,9 +1827,9 @@ class OpenGroupManagerSpec: QuickSpec { ), testMessage, ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1912,7 +1849,7 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1930,9 +1867,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1942,7 +1879,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ does nothing if we do not have the message it("does nothing if we do not have the message") { mockStorage.write { db in - OpenGroupManager.handleMessages( + communityManager.handleMessages( db, messages: [ Network.SOGS.Message( @@ -1960,9 +1897,9 @@ class OpenGroupManagerSpec: QuickSpec { reactions: nil ) ], - for: "testRoom", - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + roomToken: "testRoom", + currentUserSessionIds: [] ) } @@ -1970,7 +1907,10 @@ class OpenGroupManagerSpec: QuickSpec { } } } - + } + + // MARK: - an OpenGroupManager + describe("an OpenGroupManager") { // MARK: -- when handling direct messages context("when handling direct messages") { beforeEach { @@ -1978,7 +1918,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - ciphertext: .any, + ciphertext: Array.any, senderId: .any, recipientId: .any, serverPublicKey: .any @@ -1999,12 +1939,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does nothing if there are no messages it("does nothing if there are no messages") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2033,12 +1973,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2072,12 +2012,12 @@ class OpenGroupManagerSpec: QuickSpec { ) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2095,12 +2035,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the inbox latest message id it("updates the inbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2120,7 +2060,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - ciphertext: .any, + ciphertext: Array.any, senderId: .any, recipientId: .any, serverPublicKey: .any @@ -2133,12 +2073,12 @@ class OpenGroupManagerSpec: QuickSpec { )) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2148,12 +2088,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2163,7 +2103,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2177,8 +2117,8 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: false, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2197,12 +2137,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ updates the outbox latest message id it("updates the outbox latest message id") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2228,12 +2168,12 @@ class OpenGroupManagerSpec: QuickSpec { } mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2244,12 +2184,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ falls back to using the blinded id if no lookup is found it("falls back to using the blinded id if no lookup is found") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2276,7 +2216,7 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - ciphertext: .any, + ciphertext: Array.any, senderId: .any, recipientId: .any, serverPublicKey: .any @@ -2289,12 +2229,12 @@ class OpenGroupManagerSpec: QuickSpec { )) mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2304,12 +2244,12 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes a message with valid data it("processes a message with valid data") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [testDirectMessage], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2319,7 +2259,7 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ------ processes valid messages when combined with invalid ones it("processes valid messages when combined with invalid ones") { mockStorage.write { db in - OpenGroupManager.handleDirectMessages( + communityManager.handleDirectMessages( db, messages: [ Network.SOGS.DirectMessage( @@ -2333,8 +2273,8 @@ class OpenGroupManagerSpec: QuickSpec { testDirectMessage ], fromOutbox: true, - on: "http://127.0.0.1", - using: dependencies + server: "http://127.0.0.1", + currentUserSessionIds: [] ) } @@ -2349,202 +2289,227 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: -- when determining if a user is a moderator or an admin context("when determining if a user is a moderator or an admin") { beforeEach { - mockStorage.write { db in - _ = try GroupMember.deleteAll(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: nil, + using: dependencies + ) + ) } // MARK: ---- has no moderators by default it("has no moderators by default") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ----has no admins by default it("has no admins by default") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ---- returns true if the key is in the moderator set it("returns true if the key is in the moderator set") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .moderator, + roleStatus: .accepted, + isHidden: false + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the key is in the admin set it("returns true if the key is in the admin set") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: false - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: false + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the moderator is hidden it("returns true if the moderator is hidden") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .moderator, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .moderator, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } // MARK: ---- returns true if the admin is hidden it("returns true if the admin is hidden") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: nil, + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beTrue()) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } - // MARK: ---- returns false if the key is not a valid session id - it("returns false if the key is not a valid session id") { - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "InvalidValue", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] - ) - } - ).to(beFalse()) + // MARK: ---- returns false if the key is not an admin or moderator + it("returns false if the key is not an admin or moderator") { + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "InvalidValue", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beFalse()) } // MARK: ---- and the key belongs to the current user context("and the key belongs to the current user") { // MARK: ------ matches a blinded key it("matches a blinded key ") { - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", - role: .admin, - roleStatus: .accepted, - isHidden: true - ).insert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: [ - "05\(TestConstants.publicKey)", - "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" - ] + mockCrypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))), + secretKey: Array(Data(hex: TestConstants.edSecretKey.replacingOccurrences(of: "1", with: "2"))) ) - } - ).to(beTrue()) - } - - // MARK: ------ generates and unblinded key if the key belongs to the current user - it("generates and unblinded key if the key belongs to the current user") { - mockGeneralCache.when { $0.ed25519Seed }.thenReturn([4, 5, 6]) - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1", - currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) - } + await communityManager.updateServer( + server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.publicKey, + openGroups: [testOpenGroup], + capabilities: [.blind], + roomMembers: [ + "testRoom": [ + GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), + profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, + roleStatus: .accepted, + isHidden: true + ) + ] + ], + using: dependencies + ) + ) - expect(mockCrypto).to(call(.exactly(times: 2), matchingParameters: .all) { - $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) - }) + await expect { + await communityManager.isUserModeratorOrAdmin( + targetUserPublicKey: "05\(TestConstants.publicKey)", + server: "http://127.0.0.1", + roomToken: "testRoom", + includingHidden: true + ) + }.toEventually(beTrue()) } } } @@ -2561,7 +2526,7 @@ class OpenGroupManagerSpec: QuickSpec { server: Network.SOGS.defaultServer, roomToken: "", publicKey: Network.SOGS.defaultServerPublicKey, - isActive: false, + shouldPoll: false, name: "TestExisting", userCount: 0, infoUpdates: 0 @@ -2570,7 +2535,7 @@ class OpenGroupManagerSpec: QuickSpec { } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try Network.SOGS.preparedCapabilitiesAndRooms( - authMethod: Authentication.community( + authMethod: Authentication.Community( info: LibSession.OpenGroupCapabilityInfo( roomToken: "", server: Network.SOGS.defaultServer, @@ -2582,7 +2547,7 @@ class OpenGroupManagerSpec: QuickSpec { using: dependencies ) } - cache.defaultRoomsPublisher.sinkUntilComplete() + await communityManager.fetchDefaultRoomsIfNeeded() expect(mockNetwork) .to(call { network in @@ -2599,8 +2564,13 @@ class OpenGroupManagerSpec: QuickSpec { // MARK: ---- does not start a job to retrieve the default rooms if we already have rooms it("does not start a job to retrieve the default rooms if we already have rooms") { mockAppGroupDefaults.when { $0.bool(forKey: UserDefaults.BoolKey.isMainAppActive.rawValue) }.thenReturn(true) - cache.setDefaultRoomInfo([(room: Network.SOGS.Room.mock, openGroup: OpenGroup.mock)]) - cache.defaultRoomsPublisher.sinkUntilComplete() + await communityManager.updateRooms( + rooms: [Network.SOGS.Room.mock], + server: "http://127.0.0.1", + publicKey: Network.SOGS.defaultServerPublicKey, + areDefaultRooms: true + ) + await communityManager.fetchDefaultRoomsIfNeeded() expect(mockNetwork) .toNot(call { @@ -2693,7 +2663,7 @@ extension OpenGroup: Mocked { server: "testserver", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: true, + shouldPoll: true, name: "testRoom", userCount: 0, infoUpdates: 0 diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift index 76024092d5..a819d7f98a 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupSpec.swift @@ -23,82 +23,6 @@ class CryptoOpenGroupSpec: QuickSpec { // MARK: - Crypto for Open Group describe("Crypto for Open Group") { - // MARK: -- when encrypting with the session blinding protocol - context("when encrypting with the session blinding protocol") { - // MARK: ---- can encrypt for a blind15 recipient correctly - it("can encrypt for a blind15 recipient correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - // Note: A Nonce is used for this so we can't compare the exact value when not mocked - expect(result).toNot(beNil()) - expect(result?.count).to(equal(84)) - } - - // MARK: ---- can encrypt for a blind25 recipient correctly - it("can encrypt for a blind25 recipient correctly") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - // Note: A Nonce is used for this so we can't compare the exact value when not mocked - expect(result).toNot(beNil()) - expect(result?.count).to(equal(84)) - } - - // MARK: ---- includes a version at the start of the encrypted value - it("includes a version at the start of the encrypted value") { - let result: Data? = try? crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - - expect(result?.toHexString().prefix(2)).to(equal("00")) - } - - // MARK: ---- throws an error if the recipient isn't a blinded id - it("throws an error if the recipient isn't a blinded id") { - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "05\(TestConstants.publicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - } - .to(throwError(MessageError.encodingFailed)) - } - - // MARK: ---- throws an error if there is no ed25519 keyPair - it("throws an error if there is no ed25519 keyPair") { - mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey - ) - ) - } - .to(throwError(CryptoError.missingUserSecretKey)) - } - } - // MARK: -- when decrypting with the session blinding protocol context("when decrypting with the session blinding protocol") { // MARK: ---- can decrypt a blind15 message correctly diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index 325689d553..9473cd9e4b 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -19,7 +19,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -42,7 +42,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -66,7 +66,7 @@ class OpenGroupSpec: QuickSpec { server: "server", roomToken: "room", publicKey: "1234", - isActive: true, + shouldPoll: true, name: "name", roomDescription: nil, imageId: nil, @@ -84,7 +84,7 @@ class OpenGroupSpec: QuickSpec { roomToken: \"room\", id: \"server.room\", publicKey: \"1234\", - isActive: true, + shouldPoll: true, name: \"name\", roomDescription: null, imageId: null, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 4b41009cd6..bae0388295 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -39,7 +39,15 @@ class MessageReceiverGroupsSpec: QuickSpec { try Profile( id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" + name: "TestCurrentUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } ) @@ -113,7 +121,7 @@ class MessageReceiverGroupsSpec: QuickSpec { crypto .when { $0.verify(.signature(message: .any, publicKey: .any, signature: .any)) } .thenReturn(true) - crypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(groupKeyPair) + crypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(groupKeyPair) crypto .when { $0.verify(.memberAuthData(groupSessionId: .any, ed25519SecretKey: .any, memberAuthData: .any)) } .thenReturn(true) @@ -248,6 +256,12 @@ class MessageReceiverGroupsSpec: QuickSpec { ) // MARK: -- Messages + @TestState var decodedMessage: DecodedMessage! = DecodedMessage( + content: Data([1, 2, 3]), + sender: SessionId(.standard, hex: "1111111111111111111111111111111111111111111111111111111111111111"), + decodedEnvelope: nil, + sentTimestampMs: 1234567890000 + ) @TestState var inviteMessage: GroupUpdateInviteMessage! = { let result: GroupUpdateInviteMessage = GroupUpdateInviteMessage( inviteeSessionIdHexString: "TestId", @@ -370,8 +384,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -392,8 +408,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -422,8 +440,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -466,8 +486,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -504,8 +526,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -523,8 +547,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -543,8 +569,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -575,8 +603,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -595,8 +625,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -616,8 +648,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -642,8 +676,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -697,8 +733,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -720,8 +758,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -748,8 +788,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -769,8 +811,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -794,8 +838,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -898,8 +944,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -976,8 +1024,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1004,8 +1054,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1037,8 +1089,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1095,29 +1149,29 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- fails if it cannot convert the group seed to a groupIdentityKeyPair it("fails if it cannot convert the group seed to a groupIdentityKeyPair") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) mockStorage.write { db in - result = Result(catching: { + expect { try MessageReceiver.handleGroupUpdateMessage( db, threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }) + }.to(throwError(MessageError.invalidMessage("Test"))) } - - expect(result.failure).to(matchError(MessageError.invalidMessage)) } // MARK: ---- updates the GROUP_KEYS state correctly it("updates the GROUP_KEYS state correctly") { mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) mockStorage.write { db in @@ -1126,8 +1180,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1160,8 +1216,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: promoteMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1201,11 +1259,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1220,11 +1280,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1241,11 +1303,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1259,8 +1323,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1296,8 +1362,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1333,8 +1401,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: infoChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1386,11 +1456,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1405,11 +1477,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1426,11 +1500,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1439,7 +1515,15 @@ class MessageReceiverGroupsSpec: QuickSpec { mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", - name: "TestOtherProfile" + name: "TestOtherProfile", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } @@ -1449,8 +1533,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1485,8 +1571,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1524,8 +1612,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1564,8 +1654,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1605,8 +1697,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1640,8 +1734,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1676,8 +1772,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1713,8 +1811,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1748,8 +1848,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1784,8 +1886,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberChangedMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1826,8 +1930,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1847,11 +1953,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1866,11 +1974,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -1913,8 +2023,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1933,8 +2045,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1952,8 +2066,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -1982,8 +2098,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2045,8 +2163,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftNotificationMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2065,7 +2185,15 @@ class MessageReceiverGroupsSpec: QuickSpec { mockStorage.write { db in try Profile( id: "051111111111111111111111111111111111111111111111111111111111111112", - name: "TestOtherProfile" + name: "TestOtherProfile", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } @@ -2075,8 +2203,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: memberLeftNotificationMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2119,11 +2249,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2138,11 +2270,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2154,8 +2288,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2210,8 +2346,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2254,8 +2392,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2286,8 +2426,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2310,8 +2452,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: inviteResponseMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2361,7 +2505,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2386,7 +2531,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2411,7 +2557,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) _ = try Interaction( @@ -2436,7 +2583,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) } } @@ -2457,11 +2605,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2476,11 +2626,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2497,11 +2649,13 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } } @@ -2523,8 +2677,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2552,8 +2708,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2579,8 +2737,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2626,8 +2786,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2676,8 +2838,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2705,8 +2869,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2732,8 +2898,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2761,8 +2929,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2791,8 +2961,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2866,8 +3038,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2903,8 +3077,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: deleteContentMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: 1234567890, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -2994,7 +3170,8 @@ class MessageReceiverGroupsSpec: QuickSpec { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .none, + proProfileFeatures: .none ).inserted(db) try ConfigDump( @@ -3329,7 +3506,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageError.invalidMessage)) + .to(throwError(MessageError.invalidMessage("Test"))) } } @@ -3349,7 +3526,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageError.invalidMessage)) + .to(throwError(MessageError.invalidMessage("Test"))) } } @@ -3369,7 +3546,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageError.invalidMessage)) + .to(throwError(MessageError.invalidMessage("Test"))) } } } @@ -3427,9 +3604,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -3472,9 +3650,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } @@ -3505,9 +3684,10 @@ class MessageReceiverGroupsSpec: QuickSpec { threadId: groupId.hexString, threadVariant: .group, message: visibleMessage, + decodedMessage: decodedMessage, serverExpirationTimestamp: nil, - associatedWithProto: visibleMessageProto, suppressNotifications: false, + currentUserSessionIds: [], using: dependencies ) } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index 22989a2580..d272188de9 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -46,7 +46,15 @@ class MessageSenderGroupsSpec: AsyncSpec { try Profile( id: "05\(TestConstants.publicKey)", - name: "TestCurrentUser" + name: "TestCurrentUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ).insert(db) } ) @@ -114,7 +122,7 @@ class MessageSenderGroupsSpec: AsyncSpec { ) ) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Data(hex: groupId.hexString).bytes, @@ -149,7 +157,17 @@ class MessageSenderGroupsSpec: AsyncSpec { .when { $0.generate(.legacyEncryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(TestConstants.validImageData) crypto - .when { $0.generate(.ciphertextForGroupMessage(groupSessionId: .any, message: .any)) } + .when { + try $0.generate( + .encodedMessage( + plaintext: Array.any, + proMessageFeatures: .any, + proProfileFeatures: .any, + destination: .any, + sentTimestampMs: .any + ) + ) + } .thenReturn("TestGroupMessageCiphertext".data(using: .utf8)!) crypto .when { $0.generate(.hash(message: .any)) } @@ -494,7 +512,6 @@ class MessageSenderGroupsSpec: AsyncSpec { ), in: ConfigDump.Variant.groupInfo.namespace, authMethod: try Authentication.with( - db, swarmPublicKey: groupId.hexString, using: dependencies ), diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift index ee73e9fbc0..c56c1ea5f0 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift @@ -33,7 +33,7 @@ class MessageSenderSpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -61,7 +61,15 @@ class MessageSenderSpec: QuickSpec { beforeEach { mockCrypto .when { - $0.generate(.ciphertextWithSessionProtocol(plaintext: .any, destination: .any)) + try $0.generate( + .encodedMessage( + plaintext: Array.any, + proMessageFeatures: .any, + proProfileFeatures: .any, + destination: .any, + sentTimestampMs: .any + ) + ) } .thenReturn(Data([1, 2, 3])) mockCrypto diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 56922fde41..a73ee50536 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -380,7 +380,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -394,7 +394,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) expect { try mockNotificationsManager.ensureWeShouldShowNotification( message: message, @@ -408,7 +408,7 @@ class NotificationsManagerSpec: QuickSpec { shouldShowForMessageRequest: { true }, using: dependencies ) - }.to(throwError(MessageError.invalidMessage)) + }.to(throwError(MessageError.invalidMessage("Test"))) } // MARK: ---- throws if the message is not a preOffer diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index 1433cbab66..2136fe17dd 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -33,7 +33,7 @@ class CommunityPollerSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test", roomDescription: nil, imageId: nil, @@ -44,7 +44,7 @@ class CommunityPollerSpec: AsyncSpec { server: "testServer1", roomToken: "testRoom1", publicKey: TestConstants.publicKey, - isActive: true, + shouldPoll: true, name: "Test1", roomDescription: nil, imageId: nil, @@ -89,10 +89,10 @@ class CommunityPollerSpec: AsyncSpec { .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) - @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( - initialSetup: { cache in - cache.when { $0.pendingChanges }.thenReturn([]) - cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) + @TestState(singleton: .communityManager, in: dependencies) var mockCommunityManager: MockCommunityManager! = MockCommunityManager( + initialSetup: { manager in + manager.when { await $0.pendingChanges }.thenReturn([]) + manager.when { await $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) } ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( @@ -115,7 +115,7 @@ class CommunityPollerSpec: AsyncSpec { .when { $0.generate(.randomBytes(16)) } .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), diff --git a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift b/SessionMessagingKitTests/Types/GlobalSearchSpec.swift similarity index 87% rename from SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift rename to SessionMessagingKitTests/Types/GlobalSearchSpec.swift index 38cc13e864..c77f661d0f 100644 --- a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift +++ b/SessionMessagingKitTests/Types/GlobalSearchSpec.swift @@ -1,4 +1,4 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB @@ -8,7 +8,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit -class SessionThreadViewModelSpec: QuickSpec { +class GlobalSearchSpec: QuickSpec { override class func spec() { // MARK: Configuration @@ -30,25 +30,25 @@ class SessionThreadViewModelSpec: QuickSpec { } ) - // MARK: - a SessionThreadViewModel - describe("a SessionThreadViewModel") { + // MARK: - GlobalSearch + describe("GlobalSearch") { // MARK: -- when processing a search term context("when processing a search term") { // MARK: ---- correctly generates a safe search term it("correctly generates a safe search term") { - expect(SessionThreadViewModel.searchSafeTerm("Test")).to(equal("\"Test\"")) + expect(GlobalSearch.searchSafeTerm("Test")).to(equal("\"Test\"")) } // MARK: ---- standardises odd quote characters it("standardises odd quote characters") { - expect(SessionThreadViewModel.standardQuotes("\"")).to(equal("\"")) - expect(SessionThreadViewModel.standardQuotes("”")).to(equal("\"")) - expect(SessionThreadViewModel.standardQuotes("“")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("\"")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("”")).to(equal("\"")) + expect(GlobalSearch.standardQuotes("“")).to(equal("\"")) } // MARK: ---- splits on the space character it("splits on the space character") { - expect(SessionThreadViewModel.searchTermParts("Test Message")) + expect(GlobalSearch.searchTermParts("Test Message")) .to(equal([ "\"Test\"", "\"Message\"" @@ -57,7 +57,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- surrounds each split term with quotes it("surrounds each split term with quotes") { - expect(SessionThreadViewModel.searchTermParts("Test Message")) + expect(GlobalSearch.searchTermParts("Test Message")) .to(equal([ "\"Test\"", "\"Message\"" @@ -66,32 +66,32 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- keeps words within quotes together it("keeps words within quotes together") { - expect(SessionThreadViewModel.searchTermParts("This ”is a Test“ Message")) + expect(GlobalSearch.searchTermParts("This ”is a Test“ Message")) .to(equal([ "\"This\"", "\"is a Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" a Test Message")) + expect(GlobalSearch.searchTermParts("\"This is\" a Test Message")) .to(equal([ "\"This is\"", "\"a\"", "\"Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" \"a Test\" Message")) + expect(GlobalSearch.searchTermParts("\"This is\" \"a Test\" Message")) .to(equal([ "\"This is\"", "\"a Test\"", "\"Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\" a \"Test Message\"")) + expect(GlobalSearch.searchTermParts("\"This is\" a \"Test Message\"")) .to(equal([ "\"This is\"", "\"a\"", "\"Test Message\"" ])) - expect(SessionThreadViewModel.searchTermParts("\"This is\"\" a \"Test Message")) + expect(GlobalSearch.searchTermParts("\"This is\"\" a \"Test Message")) .to(equal([ "\"This is\"", "\" a \"", @@ -102,7 +102,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- keeps words within weird quotes together it("keeps words within weird quotes together") { - expect(SessionThreadViewModel.searchTermParts("This \"is a Test\" Message")) + expect(GlobalSearch.searchTermParts("This \"is a Test\" Message")) .to(equal([ "\"This\"", "\"is a Test\"", @@ -112,7 +112,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- removes extra whitespace it("removes extra whitespace") { - expect(SessionThreadViewModel.searchTermParts(" Test Message ")) + expect(GlobalSearch.searchTermParts(" Test Message ")) .to(equal([ "\"Test\"", "\"Message\"" @@ -146,7 +146,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- returns results it("returns results") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "Message", forTable: TestMessage.self @@ -176,7 +176,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- adds a wildcard to the final part it("adds a wildcard to the final part") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "This mes", forTable: TestMessage.self @@ -206,7 +206,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- does not add a wildcard to other parts it("does not add a wildcard to other parts") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "mes Random", forTable: TestMessage.self @@ -229,7 +229,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- finds similar words without the wildcard due to the porter tokenizer it("finds similar words without the wildcard due to the porter tokenizer") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "message z", forTable: TestMessage.self @@ -261,7 +261,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- finds results containing the words regardless of the order it("finds results containing the words regardless of the order") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "is a message", forTable: TestMessage.self @@ -293,7 +293,7 @@ class SessionThreadViewModelSpec: QuickSpec { // MARK: ---- does not find quoted parts out of order it("does not find quoted parts out of order") { let results = mockStorage.read { db in - let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + let pattern: FTS5Pattern = try GlobalSearch.pattern( db, searchTerm: "\"this is a\" \"test message\"", forTable: TestMessage.self diff --git a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift index 2254bb59fd..9f5f4dc295 100644 --- a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift +++ b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift @@ -2141,11 +2141,7 @@ class ExtensionHelperSpec: AsyncSpec { publicKey: "05\(TestConstants.publicKey)", namespace: .default, rawMessage: GetMessagesResponse.RawMessage( - base64EncodedDataString: try! MessageWrapper.wrap( - type: .sessionMessage, - timestampMs: 1234567890, - content: Data([1, 2, 3]) - ).base64EncodedString(), + base64EncodedDataString: "TestData", expirationMs: nil, hash: "TestHash", timestampMs: 1234567890 @@ -2159,8 +2155,28 @@ class ExtensionHelperSpec: AsyncSpec { dataMessage.setBody("Test") content.setDataMessage(try! dataMessage.build()) mockCrypto - .when { $0.generate(.plaintextWithSessionProtocol(ciphertext: .any)) } - .thenReturn((try! content.build().serializedData(), "05\(TestConstants.publicKey)")) + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Array.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenReturn( + DecodedMessage( + content: try! content.build().serializedData(), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) + ) } // MARK: ---- successfully loads messages diff --git a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift index edda4ebd98..bf57eae924 100644 --- a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift @@ -86,7 +86,8 @@ extension Interaction: Mocked { state: .sent, recipientReadTimestampMs: nil, mostRecentFailureText: nil, - isProMessage: false + proMessageFeatures: .mock, + proProfileFeatures: .mock ) } @@ -164,3 +165,37 @@ extension ConfigDump: Mocked { timestampMs: 1234567890 ) } + +extension SessionPro.MessageFeatures: Mocked { + static var mock: SessionPro.MessageFeatures = .all +} + +extension SessionPro.ProfileFeatures: Mocked { + static var mock: SessionPro.ProfileFeatures = .all +} + +extension CommunityManager.PendingChange: Mocked { + static var mock: CommunityManager.PendingChange = CommunityManager.PendingChange( + server: .mock, + room: .mock, + changeType: .mock, + seqNo: .mock, + metadata: .mock + ) +} + +extension CommunityManager.PendingChange.ChangeType: Mocked { + static var mock: CommunityManager.PendingChange.ChangeType = .reaction +} + +extension CommunityManager.PendingChange.ReactAction: Mocked { + static var mock: CommunityManager.PendingChange.ReactAction = .remove +} + +extension CommunityManager.PendingChange.Metadata: Mocked { + static var mock: CommunityManager.PendingChange.Metadata = .reaction( + messageId: .mock, + emoji: .mock, + action: .mock + ) +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift b/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift new file mode 100644 index 0000000000..643d482eaf --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockCommunityManager.swift @@ -0,0 +1,180 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionNetworkingKit +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockCommunityManager: Mock, CommunityManagerType { + nonisolated var defaultRooms: AsyncStream<(rooms: [Network.SOGS.Room], lastError: Error?)> { + mock() + } + var pendingChanges: [CommunityManager.PendingChange] { + get async { mock() } + } + nonisolated var syncPendingChanges: [CommunityManager.PendingChange] { + mock() + } + + // MARK: - Cache + + nonisolated func getLastSuccessfulCommunityPollTimestampSync() -> TimeInterval { + return mock() + } + + func getLastSuccessfulCommunityPollTimestamp() async -> TimeInterval { + return mock() + } + + func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) async { + return mockNoReturn(args: [timestamp]) + } + + @available(*, deprecated, message: "use `server(_:)?.currentUserSessionIds` instead") + nonisolated func currentUserSessionIdsSync(_ server: String) -> Set { + return mock(args: [server]) + } + + func fetchDefaultRoomsIfNeeded() async { mockNoReturn() } + func loadCacheIfNeeded() async { mockNoReturn() } + + func server(_ server: String) async -> CommunityManager.Server? { return mock(args: [server]) } + func server(threadId: String) async -> CommunityManager.Server? { return mock(args: [threadId]) } + func serversByThreadId() async -> [String: CommunityManager.Server] { return mock() } + func updateServer(server: CommunityManager.Server) async { return mock(args: [server]) } + func updateCapabilities( + capabilities: Set, + server: String, + publicKey: String + ) async { + mockNoReturn(args: [capabilities, server, publicKey]) + } + func updateRooms( + rooms: [Network.SOGS.Room], + server: String, + publicKey: String, + areDefaultRooms: Bool + ) async { + mockNoReturn(args: [rooms, server, publicKey, areDefaultRooms]) + } + + // MARK: - Adding & Removing + + func hasExistingCommunity(roomToken: String, server: String, publicKey: String) async -> Bool { + return mock(args: [roomToken, server, publicKey]) + } + + nonisolated func add( + _ db: ObservingDatabase, + roomToken: String, + server: String, + publicKey: String, + joinedAt: TimeInterval, + forceVisible: Bool + ) -> Bool { + return mock(args: [roomToken, server, publicKey, joinedAt, forceVisible]) + } + + nonisolated func performInitialRequestsAfterAdd( + queue: DispatchQueue, + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String + ) -> AnyPublisher { + return mock(args: [successfullyAddedGroup, roomToken, server, publicKey], untrackedArgs: [queue]) + } + + nonisolated func delete( + _ db: ObservingDatabase, + openGroupId: String, + skipLibSessionUpdate: Bool + ) throws { + return try mockThrowingNoReturn(args: [db, openGroupId, skipLibSessionUpdate]) + } + + // MARK: - Response Processing + + nonisolated func handleCapabilities( + _ db: ObservingDatabase, + capabilities: Network.SOGS.CapabilitiesResponse, + server: String, + publicKey: String + ) { + return mockNoReturn(args: [db, capabilities, server, publicKey]) + } + nonisolated func handlePollInfo( + _ db: ObservingDatabase, + pollInfo: Network.SOGS.RoomPollInfo, + server: String, + roomToken: String, + publicKey: String + ) throws { + return try mockThrowingNoReturn(args: [db, pollInfo, server, roomToken, publicKey]) + } + nonisolated func handleMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.Message], + server: String, + roomToken: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return mock(args: [db, messages, server, roomToken, currentUserSessionIds]) + } + + nonisolated func handleDirectMessages( + _ db: ObservingDatabase, + messages: [Network.SOGS.DirectMessage], + fromOutbox: Bool, + server: String, + currentUserSessionIds: Set + ) -> [MessageReceiver.InsertedInteractionInfo?] { + return mock(args: [db, messages, fromOutbox, server, currentUserSessionIds]) + } + + // MARK: - Convenience + + func addPendingReaction( + emoji: String, + id: Int64, + in roomToken: String, + on server: String, + type: CommunityManager.PendingChange.ReactAction + ) async -> CommunityManager.PendingChange { + return mock(args: [emoji, id, roomToken, server]) + } + + func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async { + mockNoReturn(args: [pendingChanges]) + } + func updatePendingChange(_ pendingChange: CommunityManager.PendingChange, seqNo: Int64?) async { + mockNoReturn(args: [pendingChange, seqNo]) + } + func removePendingChange(_ pendingChange: CommunityManager.PendingChange) async { + mockNoReturn(args: [pendingChange]) + } + + func doesOpenGroupSupport( + capability: Capability.Variant, + on maybeServer: String? + ) async -> Bool { + return mock(args: [capability, maybeServer]) + } + func allModeratorsAndAdmins( + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Set { + return mock(args: [maybeServer, roomToken, includingHidden]) + } + func isUserModeratorOrAdmin( + targetUserPublicKey: String, + server maybeServer: String?, + roomToken: String?, + includingHidden: Bool + ) async -> Bool { + return mock(args: [targetUserPublicKey, maybeServer, roomToken, includingHidden]) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift index 6e51eb570f..563bd03e46 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockDisplayPictureCache.swift @@ -7,7 +7,7 @@ import SessionUtilitiesKit @testable import SessionMessagingKit class MockDisplayPictureCache: Mock, DisplayPictureCacheType { - var downloadsToSchedule: Set { + var downloadsToSchedule: Set { get { return mock() } set { mockNoReturn(args: [newValue]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 9279000e7b..dae711016b 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -12,6 +12,8 @@ class MockLibSessionCache: Mock, LibSessionCacheType { var userSessionId: SessionId { mock() } var isEmpty: Bool { mock() } var allDumpSessionIds: Set { mock() } + var proConfig: SessionPro.ProConfig? { mock() } + var proAccessExpiryTimestampMs: UInt64 { mock() } // MARK: - State Management @@ -161,9 +163,18 @@ class MockLibSessionCache: Mock, LibSessionCacheType { displayName: Update, displayPictureUrl: Update, displayPictureEncryptionKey: Update, + proProfileFeatures: Update, isReuploadProfilePicture: Bool ) throws { - try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) + try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, proProfileFeatures, isReuploadProfilePicture]) + } + + func updateProConfig(proConfig: SessionPro.ProConfig) { + mockNoReturn(args: [proConfig]) + } + + func removeProConfig() { + mockNoReturn() } func canPerformChange( @@ -202,7 +213,7 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [threadId, threadVariant, openGroupUrlInfo]) } - func proProofMetadata(threadId: String) -> (genIndexHash: String, expiryUnixTimestampMs: Int64)? { + func proProofMetadata(threadId: String) -> LibSession.ProProofMetadata? { return mock(args: [threadId]) } @@ -265,6 +276,14 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [groupSessionId]) } + func latestGroupKey(groupSessionId: SessionId) throws -> [UInt8] { + return try mockThrowing(args: [groupSessionId]) + } + + func allActiveGroupKeys(groupSessionId: SessionId) throws -> [[UInt8]] { + return try mockThrowing(args: [groupSessionId]) + } + func isAdmin(groupSessionId: SessionId) -> Bool { return mock(args: [groupSessionId]) } @@ -421,7 +440,20 @@ extension Mock where T == LibSessionCacheType { self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) self .when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } - .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) + .thenReturn( + Profile( + id: "TestProfileId", + name: "TestProfileName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ) + ) self.when { $0.hasCredentials(groupSessionId: .any) }.thenReturn(true) self.when { $0.secretKey(groupSessionId: .any) }.thenReturn(nil) self.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) @@ -433,6 +465,7 @@ extension Mock where T == LibSessionCacheType { self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) + self.when { $0.authData(groupSessionId: .any) }.thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: nil)) self.when { $0.get(.any) }.thenReturn(false) self.when { $0.get(.any) }.thenReturn(MockLibSessionConvertible.mock) self.when { $0.get(.any) }.thenReturn(Preferences.Sound.defaultNotificationSound) diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift deleted file mode 100644 index 191d60c4d0..0000000000 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import SessionUtilitiesKit - -@testable import SessionMessagingKit - -class MockOGMCache: Mock, OGMCacheType { - var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> { - mock() - } - - var pendingChanges: [OpenGroupManager.PendingChange] { - get { return mock() } - set { mockNoReturn(args: [newValue]) } - } - - func getLastSuccessfulCommunityPollTimestamp() -> TimeInterval { - return mock() - } - - func setLastSuccessfulCommunityPollTimestamp(_ timestamp: TimeInterval) { - mockNoReturn(args: [timestamp]) - } - - func setDefaultRoomInfo(_ info: [OpenGroupManager.DefaultRoomInfo]) { - mockNoReturn(args: [info]) - } -} diff --git a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift index 80ae78bb07..6df9a0b218 100644 --- a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift @@ -48,7 +48,7 @@ class SOGSAPISpec: QuickSpec { .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index beaaa7ed26..350ae49591 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -44,8 +44,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { @TestState var viewModel: ThreadDisappearingMessagesSettingsViewModel! = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: DisappearingMessagesConfiguration.defaultWith("TestId"), using: dependencies ) @@ -143,8 +142,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { viewModel = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: config, using: dependencies ) @@ -265,8 +263,7 @@ class ThreadDisappearingMessagesSettingsViewModelSpec: AsyncSpec { viewModel = ThreadDisappearingMessagesSettingsViewModel( threadId: "TestId", threadVariant: .contact, - currentUserIsClosedGroupMember: nil, - currentUserIsClosedGroupAdmin: nil, + currentUserRole: nil, config: config, using: dependencies ) diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 7a8f90c375..a9282eabfa 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -36,8 +36,30 @@ class ThreadSettingsViewModelSpec: AsyncSpec { variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey) ).insert(db) - try Profile(id: userPubkey, name: "TestMe").insert(db) - try Profile(id: user2Pubkey, name: "TestUser").insert(db) + try Profile( + id: userPubkey, + name: "TestMe", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ).insert(db) + try Profile( + id: user2Pubkey, + name: "TestUser", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ).insert(db) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -129,9 +151,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: user2Pubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: user2Pubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: user2Pubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -164,9 +205,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: userPubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: userPubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: userPubkey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: userPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -177,7 +237,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("sessionSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name @@ -217,9 +278,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: user2Pubkey, - threadVariant: .contact, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: user2Pubkey, + variant: .contact, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: user2Pubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -230,7 +310,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("sessionSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("sessionSettings".localized())) } // MARK: ---- has the correct display name @@ -377,9 +458,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: legacyGroupPubkey, - threadVariant: .legacyGroup, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: legacyGroupPubkey, + variant: .legacyGroup, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: legacyGroupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -390,7 +490,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name @@ -436,9 +537,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: legacyGroupPubkey, - threadVariant: .legacyGroup, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: legacyGroupPubkey, + variant: .legacyGroup, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: legacyGroupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -492,9 +612,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -505,7 +644,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name @@ -556,9 +696,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -587,9 +746,28 @@ class ThreadSettingsViewModelSpec: AsyncSpec { beforeEach { dependencies[feature: .updatedGroupsAllowDescriptionEditing] = true - viewModel = ThreadSettingsViewModel( - threadId: groupPubkey, - threadVariant: .group, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: groupPubkey, + variant: .group, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: groupPubkey), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -793,16 +971,35 @@ class ThreadSettingsViewModelSpec: AsyncSpec { server: "testServer", roomToken: "testRoom", publicKey: TestConstants.serverPublicKey, - isActive: false, + shouldPoll: false, name: "TestCommunity", userCount: 1, infoUpdates: 1 ).insert(db) } - viewModel = ThreadSettingsViewModel( - threadId: communityId, - threadVariant: .community, + viewModel = await ThreadSettingsViewModel( + threadInfo: ConversationInfoViewModel( + thread: SessionThread( + id: communityId, + variant: .community, + creationDateTimestamp: 1234567890 + ), + dataCache: ConversationDataCache( + userSessionId: SessionId(.standard, hex: TestConstants.publicKey), + context: ConversationDataCache.Context( + source: .conversationSettings(threadId: communityId), + requireFullRefresh: false, + requireAuthMethodFetch: false, + requiresMessageRequestCountUpdate: false, + requiresInitialUnreadInteractionInfo: false, + requireRecentReactionEmojiUpdate: false + ) + ), + targetInteractionId: nil, + searchText: nil, + using: dependencies + ), didTriggerSearch: { didTriggerSearchCallbackTriggered = true }, @@ -813,7 +1010,8 @@ class ThreadSettingsViewModelSpec: AsyncSpec { // MARK: ---- has the correct title it("has the correct title") { - expect(viewModel.title).to(equal("deleteAfterGroupPR1GroupSettings".localized())) + await expect { await viewModel.title } + .toEventually(equal("deleteAfterGroupPR1GroupSettings".localized())) } // MARK: ---- has the correct display name diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index a243b4428f..c8330913d0 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -39,7 +39,7 @@ class OnboardingSpec: AsyncSpec { .when { $0.generate(.randomBytes(.any)) } .thenReturn(Data([1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8])) crypto - .when { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenReturn(Data([ 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, @@ -47,7 +47,7 @@ class OnboardingSpec: AsyncSpec { 1, 2 ])) crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -223,7 +223,7 @@ class OnboardingSpec: AsyncSpec { beforeEach { mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn(KeyPair(publicKey: [1, 2, 3], secretKey: [4, 5, 6])) mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } @@ -250,7 +250,7 @@ class OnboardingSpec: AsyncSpec { .when { $0.ed25519SecretKey } .thenReturn(Array(Data(hex: TestConstants.edSecretKey))) mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -296,10 +296,10 @@ class OnboardingSpec: AsyncSpec { // MARK: ---- and failing to generate an x25519KeyPair context("and failing to generate an x25519KeyPair") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: Array.any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } mockCrypto - .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenThrow(MockError.mockedData) mockCrypto .when { @@ -367,7 +367,20 @@ class OnboardingSpec: AsyncSpec { visibleMessage: .any ) } - .thenReturn(Profile(id: "TestProfileId", name: "TestProfileName")) + .thenReturn( + Profile( + id: "TestProfileId", + name: "TestProfileName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil + ) + ) } // MARK: ------ loads from libSession @@ -396,10 +409,10 @@ class OnboardingSpec: AsyncSpec { // MARK: ------ after generating new credentials context("after generating new credentials") { beforeEach { - mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: .any)) } - mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: .any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519KeyPair(seed: Array.any)) } + mockCrypto.removeMocksFor { $0.generate(.ed25519Seed(ed25519SecretKey: Array.any)) } mockCrypto - .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: .any)) } + .when { try $0.tryGenerate(.ed25519Seed(ed25519SecretKey: Array.any)) } .thenThrow(MockError.mockedData) mockCrypto .when { @@ -458,7 +471,7 @@ class OnboardingSpec: AsyncSpec { describe("an Onboarding Cache when setting seed data") { beforeEach { mockCrypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -642,7 +655,10 @@ class OnboardingSpec: AsyncSpec { displayPictureUrl: nil, displayPictureEncryptionKey: nil, profileLastUpdated: 12345678900, - blocksCommunityMessageRequests: nil + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) ])) } @@ -681,7 +697,10 @@ class OnboardingSpec: AsyncSpec { displayPictureUrl: nil, displayPictureEncryptionKey: nil, profileLastUpdated: profile.profileLastUpdated, - blocksCommunityMessageRequests: nil + blocksCommunityMessageRequests: nil, + proFeatures: .none, + proExpiryUnixTimestampMs: 0, + proGenIndexHashHex: nil ) )) expect(profile.profileLastUpdated).toNot(beNil()) @@ -852,7 +871,7 @@ class OnboardingSpec: AsyncSpec { displayName: .set(to: "TestPolledName"), displayPictureUrl: .set(to: "http://filev2.getsession.org/file/1234"), displayPictureEncryptionKey: .set(to: Data([1, 2, 3])), - proFeatures: .set(to: .none), + proProfileFeatures: .set(to: .none), isReuploadProfilePicture: false ) testCacheProfile = cache.profile diff --git a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift index 4eaa6a0480..426d368bc3 100644 --- a/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift +++ b/SessionUtilitiesKitTests/General/GeneralCacheSpec.swift @@ -15,7 +15,7 @@ class GeneralCacheSpec: QuickSpec { @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in crypto - .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .when { $0.generate(.ed25519KeyPair(seed: Array.any)) } .thenReturn( KeyPair( publicKey: Array(Data(hex: TestConstants.edPublicKey)), @@ -57,7 +57,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when given a seckey that is too short it("remains invalid when given a seckey that is too short") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: [1, 2, 3]) @@ -68,7 +68,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when ed key pair generation fails it("remains invalid when ed key pair generation fails") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) @@ -79,7 +79,7 @@ class GeneralCacheSpec: QuickSpec { // MARK: -- remains invalid when x25519 pubkey generation fails it("remains invalid when x25519 pubkey generation fails") { - mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: .any)) }.thenReturn(nil) + mockCrypto.when { $0.generate(.x25519(ed25519Pubkey: Array.any)) }.thenReturn(nil) let cache: General.Cache = General.Cache(using: dependencies) cache.setSecretKey(ed25519SecretKey: Array(Data(hex: TestConstants.edSecretKey))) diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift index 8458834245..cf09d793d1 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -30,6 +30,7 @@ extension Mocked { static var any: Self { mock } } extension Int: Mocked { static var mock: Int { 0 } } extension Int64: Mocked { static var mock: Int64 { 0 } } +extension UInt64: Mocked { static var mock: UInt64 { 0 } } extension Dictionary: Mocked { static var mock: Self { [:] } } extension Array: Mocked { static var mock: Self { [] } } extension Set: Mocked { static var mock: Self { [] } } From 6194833ee1488cbd232c52e85ad22689c5f296a3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 09:17:33 +1100 Subject: [PATCH 41/66] Fixed a few more unit tests --- .../SessionPro/SessionProManager.swift | 6 +- ...RetrieveDefaultOpenGroupRoomsJobSpec.swift | 3 + .../LibSession/LibSessionGroupInfoSpec.swift | 6 +- .../Open Groups/CommunityManagerSpec.swift | 85 ++++++++++++++----- .../MessageSenderGroupsSpec.swift | 19 +++-- .../_TestUtilities/MockLibSessionCache.swift | 1 + SessionTests/Onboarding/OnboardingSpec.swift | 2 +- 7 files changed, 91 insertions(+), 31 deletions(-) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 46f836a255..3fc1a62fd8 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -62,10 +62,14 @@ public actor SessionProManager: SessionProManagerType { self.syncState = SessionProManagerSyncState(using: dependencies) Task.detached(priority: .medium) { [weak self] in + await self?.startProMockingObservations() + + // TODO: [PRO] Probably need to kick of the below tasks within 'startProMockingObservations' if Session Pro gets enabled (will need to check that they aren't already running though) + guard dependencies[feature: .sessionProEnabled] else { return } + await self?.updateWithLatestFromUserConfig() await self?.startRevocationListTask() await self?.startStoreKitObservations() - await self?.startProMockingObservations() /// Kick off a refresh so we know we have the latest state (if it's the main app) if dependencies[singleton: .appContext].isMainApp { diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 46b44dca6e..4010645578 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -96,6 +96,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { manager .when { await $0.updateRooms(rooms: .any, server: .any, publicKey: .any, areDefaultRooms: .any) } .thenReturn(()) + manager + .when { $0.handleCapabilities(.any, capabilities: .any, server: .any, publicKey: .any) } + .thenReturn(()) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index df91cba449..8c6cb60374 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -312,7 +312,7 @@ class LibSessionGroupInfoSpec: QuickSpec { count: DisplayPictureManager.encryptionKeySize ) ), - timestamp: 1234567891 + timestamp: 1234567890 ) ), canStartJob: true @@ -873,6 +873,10 @@ class LibSessionGroupInfoSpec: QuickSpec { proProfileFeatures: .none ).inserted(db) } + mockLibSessionCache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) createGroupOutput.groupState[.groupInfo]?.conf.map { groups_info_set_delete_before($0, 123456) } diff --git a/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift index f7b82dfbbe..bd1b3c8a59 100644 --- a/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift @@ -1656,6 +1656,31 @@ class CommunityManagerSpec: AsyncSpec { // MARK: -- when handling messages context("when handling messages") { beforeEach { + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenReturn( + DecodedMessage( + content: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + + Data([0x80]) + + Data([UInt8](repeating: 0, count: 32)), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) + ) mockStorage.write { db in try testGroupThread.insert(db) try testOpenGroup.insert(db) @@ -1916,21 +1941,29 @@ class CommunityManagerSpec: AsyncSpec { beforeEach { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: Array.any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + - Data([0x80]) + - Data([UInt8](repeating: 0, count: 32)), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenReturn( + DecodedMessage( + content: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + + Data([0x80]) + + Data([UInt8](repeating: 0, count: 32)), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) + ) mockCrypto .when { $0.generate(.x25519(ed25519Pubkey: .any)) } .thenReturn(Data(hex: TestConstants.publicKey).bytes) @@ -2058,19 +2091,27 @@ class CommunityManagerSpec: AsyncSpec { it("ignores a message with invalid data") { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: Array.any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data("TestInvalid".bytes), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenReturn( + DecodedMessage( + content: Data("TestInvalid".bytes), + sender: SessionId(.standard, hex: TestConstants.publicKey), + decodedEnvelope: nil, + sentTimestampMs: 1234567890 + ) + ) mockStorage.write { db in communityManager.handleDirectMessages( diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index d272188de9..778a3153fe 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -172,6 +172,9 @@ class MessageSenderGroupsSpec: AsyncSpec { crypto .when { $0.generate(.hash(message: .any)) } .thenReturn(Array(Data(hex: "01010101010101010101010101010101"))) + crypto + .when { $0.generate(.signatureSubaccount(config: .any, verificationBytes: .any, memberAuthData: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) } ) @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( @@ -252,6 +255,9 @@ class MessageSenderGroupsSpec: AsyncSpec { cache .when { try $0.pendingPushes(swarmPublicKey: .any) } .thenReturn(LibSession.PendingPushes(obsoleteHashes: ["testHash"])) + cache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( @@ -479,7 +485,7 @@ class MessageSenderGroupsSpec: AsyncSpec { ] ) ) - let expectedRequest: Network.PreparedRequest = mockStorage.write { db in + let expectedRequest: Network.PreparedRequest? = mockStorage.write { db in // Need the auth data to exist in the database to prepare the request _ = try SessionThread.upsert( db, @@ -530,7 +536,8 @@ class MessageSenderGroupsSpec: AsyncSpec { try SessionThread.filter(id: groupId.hexString).deleteAll(db) return preparedRequest - }! + } + try require(expectedRequest).toNot(beNil()) let result = await Result { try await MessageSender.createGroup( @@ -550,10 +557,10 @@ class MessageSenderGroupsSpec: AsyncSpec { .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.SnodeAPI.Endpoint.sequence, - destination: expectedRequest.destination, - body: expectedRequest.body, - requestTimeout: expectedRequest.requestTimeout, - requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout + destination: expectedRequest!.destination, + body: expectedRequest!.body, + requestTimeout: expectedRequest!.requestTimeout, + requestAndPathBuildTimeout: expectedRequest!.requestAndPathBuildTimeout ) }) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index dae711016b..0bbe0d8f3b 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -463,6 +463,7 @@ extension Mock where T == LibSessionCacheType { self.when { $0.wasKickedFromGroup(groupSessionId: .any) }.thenReturn(false) self.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroupName") self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) + self.when { $0.groupInfo(for: .any) }.thenReturn([]) self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) self.when { $0.authData(groupSessionId: .any) }.thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: nil)) diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index c8330913d0..e474a4f2e1 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -697,7 +697,7 @@ class OnboardingSpec: AsyncSpec { displayPictureUrl: nil, displayPictureEncryptionKey: nil, profileLastUpdated: profile.profileLastUpdated, - blocksCommunityMessageRequests: nil, + blocksCommunityMessageRequests: true, proFeatures: .none, proExpiryUnixTimestampMs: 0, proGenIndexHashHex: nil From 6d8b2de49b7fa4b14139649c31d4ecfcf6215f9e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 09:57:09 +1100 Subject: [PATCH 42/66] Fixed a couple of bugs found while fixing the unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Tweaked group message deletion to determine admin status via libSession instead of the database (since it now retrieves keys from libSession) • Fixed a bug where interactions may not be able to be marked as deleted if there was an attachment without a downloadUrl --- SessionMessagingKit/Database/Models/Interaction.swift | 1 + .../LibSession/Config Handling/LibSession+GroupInfo.swift | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index c6cf19ed73..3c000f024d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -1441,6 +1441,7 @@ public extension Interaction { let attachmentDownloadUrls: [String] = try Attachment .select(.downloadUrl) .filter(ids: interactionAttachments.map { $0.attachmentId }) + .filter(Attachment.Columns.downloadUrl != nil) .asRequest(of: String.self) .fetchAll(db) try Attachment diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 6d45b282c3..dabd016062 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -188,11 +188,7 @@ internal extension LibSessionCacheType { // Check if the user is an admin in the group var messageHashesToDelete: Set = [] - let isAdmin: Bool = ((try? ClosedGroup - .filter(id: groupSessionId.hexString) - .select(.groupIdentityPrivateKey) - .asRequest(of: Data.self) - .fetchOne(db)) != nil) + let isAdmin: Bool = isAdmin(groupSessionId: groupSessionId) // If there is a `delete_before` setting then delete all messages before the provided timestamp let deleteBeforeTimestamp: Int64 = groups_info_get_delete_before(conf) From 965a778672e8b4366de763d1593b27fc97d3934e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 09:57:25 +1100 Subject: [PATCH 43/66] Fixed some more tests --- .../Crypto/CryptoSMKSpec.swift | 50 ++++++++++--------- .../LibSession/LibSessionGroupInfoSpec.swift | 10 +++- .../MessageReceiverGroupsSpec.swift | 23 +-------- 3 files changed, 36 insertions(+), 47 deletions(-) diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 6bf9aa3a5f..f0abc5255b 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -84,7 +84,7 @@ class CryptoSMKSpec: QuickSpec { // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) - expect(result?.count).to(equal(155)) + expect(result?.count).to(equal(397)) } // MARK: ---- throws an error if there is no ed25519 keyPair @@ -109,28 +109,34 @@ class CryptoSMKSpec: QuickSpec { // MARK: -- when decrypting with the session protocol context("when decrypting with the session protocol") { @TestState var result: DecodedMessage? + @TestState var encodedMessage: Data! = Data( + base64Encoded: "CAESvwEKABIAGrYBCAYSACjQiOyP9yM4AUKmAfjX/WXVFs+QE5Eh54Esw9/N" + + "lYza3k8MOvcRAI7y8k0JzLsm/KpXxKP7Zx7+5YyII9sCRXzFK2U4/X9SSMN088YEr/5wKoDfL5q" + + "PQbN70aa59WS8YE+yWcniQO0KXfAzr6Acn40fsa9BMr9tnQLfvxY8vD7qBz9iEOV9jTxPzxUoD+" + + "JelIbsv2qlkOl9vs166NC/Y772NZmUAR5u1ewL4SYEWkqX5R4gAA==" + ) // MARK: ---- successfully decrypts a message it("successfully decrypts a message") { - result = try? crypto.generate( - .decodedMessage( - encodedMessage: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - origin: .swarm( - publicKey: TestConstants.publicKey, - namespace: .default, - serverHash: "12345", - serverTimestampMs: 1234567890, - serverExpirationTimestamp: 1234567890 + try require { + result = try crypto.tryGenerate( + .decodedMessage( + encodedMessage: encodedMessage, + origin: .swarm( + publicKey: "05\(TestConstants.publicKey)", + namespace: .default, + serverHash: "12345", + serverTimestampMs: 1234567890, + serverExpirationTimestamp: 1234567890 + ) ) ) - ) + }.toNot(throwError()) - expect(String(data: (result?.content ?? Data()), encoding: .utf8)).to(equal("TestMessage")) - expect(result?.sender.hexString) + let proto: SNProtoContent! = try require { try result!.decodeProtoContent() } + .toNot(throwError()) + expect(proto.dataMessage?.body).to(equal("TestMessage")) + expect(result!.sender.hexString) .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } @@ -141,13 +147,9 @@ class CryptoSMKSpec: QuickSpec { expect { result = try crypto.tryGenerate( .decodedMessage( - encodedMessage: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, + encodedMessage: encodedMessage, origin: .swarm( - publicKey: TestConstants.publicKey, + publicKey: "05\(TestConstants.publicKey)", namespace: .default, serverHash: "12345", serverTimestampMs: 1234567890, @@ -166,7 +168,7 @@ class CryptoSMKSpec: QuickSpec { .decodedMessage( encodedMessage: Data([1, 2, 3]), origin: .swarm( - publicKey: TestConstants.publicKey, + publicKey: "05\(TestConstants.publicKey)", namespace: .default, serverHash: "12345", serverTimestampMs: 1234567890, diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 8c6cb60374..e1289d57a1 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -95,6 +95,14 @@ class LibSessionGroupInfoSpec: QuickSpec { cache.when { $0.configNeedsDump(.any) }.thenReturn(true) } ) + @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( + initialSetup: { crypto in + crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) + crypto + .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } + .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + } + ) // MARK: - LibSessionGroupInfo describe("LibSessionGroupInfo") { @@ -898,7 +906,7 @@ class LibSessionGroupInfoSpec: QuickSpec { using: dependencies ) expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.SnodeAPI.Endpoint.deleteMessages, destination: expectedRequest.destination, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index bae0388295..a0dff46dca 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -2615,27 +2615,6 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - deleteContentMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: deleteContentMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { mockCrypto @@ -3546,7 +3525,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageError.invalidMessage("Test"))) + .to(throwError(MessageError.ignorableMessage)) } } } From 508448cc0a8ef0233797914bf3bed74a6e829f3e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 11:22:43 +1100 Subject: [PATCH 44/66] Fixed a bug where default db values weren't being inserted during migration --- .../Migrations/_048_SessionProChanges.swift | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift index 964c913e75..bb02c5ffe6 100644 --- a/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_048_SessionProChanges.swift @@ -12,16 +12,35 @@ enum _048_SessionProChanges: Migration { static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { try db.alter(table: "interaction") { t in t.drop(column: "isProMessage") - t.add(column: "proMessageFeatures", .integer).defaults(to: 0) - t.add(column: "proProfileFeatures", .integer).defaults(to: 0) + t.add(column: "proMessageFeatures", .integer) + .notNull() + .defaults(to: 0) + t.add(column: "proProfileFeatures", .integer) + .notNull() + .defaults(to: 0) } try db.alter(table: "profile") { t in - t.add(column: "proFeatures", .integer).defaults(to: 0) - t.add(column: "proExpiryUnixTimestampMs", .integer).defaults(to: 0) + t.add(column: "proFeatures", .integer) + .notNull() + .defaults(to: 0) + t.add(column: "proExpiryUnixTimestampMs", .integer) + .notNull() + .defaults(to: 0) t.add(column: "proGenIndexHashHex", .text) } + /// SQLite doesn't retroactively insert default values into columns so we need to add them now + try db.execute(sql: """ + UPDATE interaction + SET proMessageFeatures = 0, proProfileFeatures = 0 + """) + + try db.execute(sql: """ + UPDATE profile + SET proFeatures = 0, proExpiryUnixTimestampMs = 0 + """) + MigrationExecution.updateProgress(1) } } From 5e53101c803e01f3224b126c493d22f57c3dd5fa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 11:26:59 +1100 Subject: [PATCH 45/66] Fixed a bug where default communities would use auth to retrieve display pics --- .../Jobs/RetrieveDefaultOpenGroupRoomsJob.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift index 20f703e010..db62c6a2f8 100644 --- a/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/RetrieveDefaultOpenGroupRoomsJob.swift @@ -76,7 +76,8 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { target: .community( imageId: imageId, roomToken: info.token, - server: Network.SOGS.defaultServer + server: Network.SOGS.defaultServer, + skipAuthentication: true ), timestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) From e9cb273748d2eb24968d427e6684cb630674d9c9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 12:24:42 +1100 Subject: [PATCH 46/66] Fixed a bug where the CommunityManager wasn't updating it's syncState --- SessionMessagingKit/Open Groups/CommunityManager.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index 1154c5b5c8..cb4e4affbe 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -158,6 +158,7 @@ public actor CommunityManager: CommunityManagerType { public func updateServer(server: Server) async { _servers[server.server.lowercased()] = server + syncState.update(servers: .set(to: _servers)) } public func updateCapabilities( @@ -182,6 +183,8 @@ public actor CommunityManager: CommunityManagerType { using: dependencies ) } + + syncState.update(servers: .set(to: _servers)) } public func updateRooms( @@ -213,6 +216,7 @@ public actor CommunityManager: CommunityManagerType { rooms: .set(to: rooms), using: dependencies ) + syncState.update(servers: .set(to: _servers)) } public func removeRoom(server: String, roomToken: String) async { @@ -224,6 +228,7 @@ public actor CommunityManager: CommunityManagerType { rooms: .set(to: Array(server.rooms.removingValue(forKey: roomToken).values)), using: dependencies ) + syncState.update(servers: .set(to: _servers)) } // MARK: - Adding & Removing @@ -1178,23 +1183,27 @@ public actor CommunityManager: CommunityManagerType { ) ) pendingChanges.append(pendingChange) + syncState.update(pendingChanges: .set(to: pendingChanges)) return pendingChange } public func setPendingChanges(_ pendingChanges: [CommunityManager.PendingChange]) async { self.pendingChanges = pendingChanges + syncState.update(pendingChanges: .set(to: self.pendingChanges)) } public func updatePendingChange(_ pendingChange: PendingChange, seqNo: Int64?) async { if let index = pendingChanges.firstIndex(of: pendingChange) { pendingChanges[index].seqNo = seqNo + syncState.update(pendingChanges: .set(to: pendingChanges)) } } public func removePendingChange(_ pendingChange: PendingChange) async { if let index = pendingChanges.firstIndex(of: pendingChange) { pendingChanges.remove(at: index) + syncState.update(pendingChanges: .set(to: pendingChanges)) } } From 778a5b50004026105acdc976bca72eac7454bbd5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 12:44:05 +1100 Subject: [PATCH 47/66] Fixed a bug where communities wouldn't identify mods/admins correctly in some cases --- SessionMessagingKit/Open Groups/CommunityManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index cb4e4affbe..58933aaf88 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -1257,7 +1257,7 @@ public actor CommunityManager: CommunityManagerType { /// Check if the `publicKey` matches a visible admin or moderator let isVisibleModOrAdmin: Bool = ( - !possibleKeys.isDisjoint(with: Set(room.admins)) && + !possibleKeys.isDisjoint(with: Set(room.admins)) || !possibleKeys.isDisjoint(with: Set(room.moderators)) ) @@ -1266,9 +1266,9 @@ public actor CommunityManager: CommunityManagerType { return isVisibleModOrAdmin } - /// Chcek if the `publicKey` is a hidden admin/mod + /// Check if the `publicKey` is a hidden admin/mod return ( - !possibleKeys.isDisjoint(with: Set(room.hiddenAdmins ?? [])) && + !possibleKeys.isDisjoint(with: Set(room.hiddenAdmins ?? [])) || !possibleKeys.isDisjoint(with: Set(room.hiddenModerators ?? [])) ) } From 0a3f7b418f9e83ebeedf4755aff83a7e25ccefaf Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 12:44:15 +1100 Subject: [PATCH 48/66] Fixed a bunch more unit tests --- ...RetrieveDefaultOpenGroupRoomsJobSpec.swift | 373 ++--------- .../Open Groups/CommunityManagerSpec.swift | 632 ++++++++---------- .../MessageReceiverGroupsSpec.swift | 307 +++++---- 3 files changed, 516 insertions(+), 796 deletions(-) diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 4010645578..e96482230a 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -10,7 +10,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { +class RetrieveDefaultOpenGroupRoomsJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -192,97 +192,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(wasDeferred).to(beFalse()) } - // MARK: -- creates an inactive entry in the database if one does not exist - it("creates an inactive entry in the database if one does not exist") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.errorResponse()) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(1)) - expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal([""])) - expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.shouldPoll }).to(equal([false])) - expect(openGroups?.map { $0.name }).to(equal([""])) - } - - // MARK: -- does not create a new entry if one already exists - it("does not create a new entry if one already exists") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.errorResponse()) - - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - shouldPoll: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 - ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(1)) - expect(openGroups?.map { $0.server }).to(equal([Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal([""])) - expect(openGroups?.map { $0.publicKey }).to(equal([Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.shouldPoll }).to(equal([false])) - expect(openGroups?.map { $0.name }).to(equal(["TestExisting"])) - } - // MARK: -- sends the correct request it("sends the correct request") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "", - publicKey: Network.SOGS.defaultServerPublicKey, - shouldPoll: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 - ) - .insert(db) - } let expectedRequest: Network.PreparedRequest! = mockStorage.read { db in try Network.SOGS.preparedCapabilitiesAndRooms( authMethod: Authentication.Community( @@ -307,8 +218,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call { network in + await expect(mockNetwork) + .toEventually(call { network in network.send( endpoint: Network.SOGS.Endpoint.sequence, destination: expectedRequest.destination, @@ -321,47 +232,8 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(expectedRequest?.headers).to(beEmpty()) } - // MARK: -- will retry 8 times before it fails - it("will retry 8 times before it fails") { - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn(MockNetwork.nullResponse()) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, error_, permanentFailure_ in - error = error_ - permanentFailure = permanentFailure_ - }, - deferred: { _ in }, - using: dependencies - ) - - expect(error).to(matchError(NetworkError.parsingFailed)) - expect(mockNetwork) // First attempt + 8 retries - .to(call(.exactly(times: 9)) { network in - network.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - }) - } - - // MARK: -- stores the updated capabilities - it("stores the updated capabilities") { + // MARK: -- sends the updated capabilities to the CommunityManager for storage + it("sends the updated capabilities to the CommunityManager for storage") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -371,16 +243,24 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let capabilities: [Capability]? = mockStorage.read { db in try Capability.fetchAll(db) } - expect(capabilities?.count).to(equal(2)) - expect(capabilities?.map { $0.openGroupServer }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(capabilities?.map { $0.variant }).to(equal([.blind, .reactions])) - expect(capabilities?.map { $0.isMissing }).to(equal([false, false])) + await expect(mockCommunityManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + $0.handleCapabilities( + .any, + capabilities: Network.SOGS.CapabilitiesResponse( + capabilities: [ + Capability.Variant.blind.rawValue, + Capability.Variant.reactions.rawValue + ], + missing: nil + ), + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey + ) + }) } - // MARK: -- inserts the returned rooms - it("inserts the returned rooms") { + // MARK: -- stores the returned rooms in the CommunityManager + it("stores the returned rooms in the CommunityManager") { RetrieveDefaultOpenGroupRoomsJob.run( job, scheduler: DispatchQueue.main, @@ -390,87 +270,25 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(3)) // 1 for the entry used to fetch the default rooms - expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }).to(equal(["", "testRoom", "testRoom2"])) - expect(openGroups?.map { $0.publicKey }) - .to(equal([ - Network.SOGS.defaultServerPublicKey, - Network.SOGS.defaultServerPublicKey, - Network.SOGS.defaultServerPublicKey - ])) - expect(openGroups?.map { $0.shouldPoll }).to(equal([false, false, false])) - expect(openGroups?.map { $0.name }).to(equal(["", "TestRoomName", "TestRoomName2"])) - } - - // MARK: -- does not override existing rooms that were returned - it("does not override existing rooms that were returned") { - mockStorage.write { db in - try OpenGroup( + await expect(mockCommunityManager).toEventually(call(.exactly(times: 1), matchingParameters: .all) { + await $0.updateRooms( + rooms: [ + Network.SOGS.Room.mock.with( + token: "testRoom", + name: "TestRoomName" + ), + Network.SOGS.Room.mock.with( + token: "testRoom2", + name: "TestRoomName2", + infoUpdates: 12, + imageId: "12" + ) + ], server: Network.SOGS.defaultServer, - roomToken: "testRoom", publicKey: Network.SOGS.defaultServerPublicKey, - shouldPoll: false, - name: "TestExisting", - userCount: 0, - infoUpdates: 0 + areDefaultRooms: true ) - .insert(db) - } - mockNetwork - .when { - $0.send( - endpoint: MockEndpoint.any, - destination: .any, - body: .any, - requestTimeout: .any, - requestAndPathBuildTimeout: .any - ) - } - .thenReturn( - MockNetwork.batchResponseData( - with: [ - (Network.SOGS.Endpoint.capabilities, Network.SOGS.CapabilitiesResponse.mockBatchSubResponse()), - ( - Network.SOGS.Endpoint.rooms, - try! JSONEncoder().with(outputFormatting: .sortedKeys).encode( - Network.BatchSubResponse( - code: 200, - headers: [:], - body: [ - Network.SOGS.Room.mock.with( - token: "testRoom", - name: "TestReplacementName" - ) - ], - failedToParseBody: false - ) - ) - ) - ] - ) - ) - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - let openGroups: [OpenGroup]? = mockStorage.read { db in try OpenGroup.fetchAll(db) } - expect(openGroups?.count).to(equal(2)) // 1 for the entry used to fetch the default rooms - expect(openGroups?.map { $0.server }) - .to(equal([Network.SOGS.defaultServer, Network.SOGS.defaultServer])) - expect(openGroups?.map { $0.roomToken }.sorted()).to(equal(["", "testRoom"])) - expect(openGroups?.map { $0.publicKey }) - .to(equal([Network.SOGS.defaultServerPublicKey, Network.SOGS.defaultServerPublicKey])) - expect(openGroups?.map { $0.shouldPoll }).to(equal([false, false])) - expect(openGroups?.map { $0.name }.sorted()).to(equal(["", "TestExisting"])) + }) } // MARK: -- schedules a display picture download @@ -484,75 +302,26 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(matchingParameters: .all) { - $0.add( - .any, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: "12", - roomToken: "testRoom2", - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: 1234567890 - ) - ), - dependantJob: nil, - canStartJob: true - ) - }) - } - - // MARK: -- schedules a display picture download if the imageId has changed - it("schedules a display picture download if the imageId has changed") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - shouldPoll: false, - name: "TestExisting", - imageId: "10", - userCount: 0, - infoUpdates: 10 + await expect(mockJobRunner).toEventually(call(matchingParameters: .all) { + $0.add( + .any, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .community( + imageId: "12", + roomToken: "testRoom2", + server: Network.SOGS.defaultServer, + skipAuthentication: true + ), + timestamp: 1234567890 + ) + ), + dependantJob: nil, + canStartJob: true ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - expect(mockJobRunner) - .to(call(matchingParameters: .all) { - $0.add( - .any, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .community( - imageId: "12", - roomToken: "testRoom2", - server: Network.SOGS.defaultServer, - skipAuthentication: true - ), - timestamp: 1234567890 - ) - ), - dependantJob: nil, - canStartJob: true - ) - }) + }) } // MARK: -- does not schedule a display picture download if there is no imageId @@ -609,36 +378,6 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) } - // MARK: -- does not schedule a display picture download if the imageId matches and the image has already been downloaded - it("does not schedule a display picture download if the imageId matches and the image has already been downloaded") { - mockStorage.write { db in - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom2", - publicKey: Network.SOGS.defaultServerPublicKey, - shouldPoll: false, - name: "TestExisting", - imageId: "12", - userCount: 0, - infoUpdates: 12, - displayPictureOriginalUrl: "TestUrl" - ) - .insert(db) - } - - RetrieveDefaultOpenGroupRoomsJob.run( - job, - scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, - using: dependencies - ) - - expect(mockJobRunner) - .toNot(call { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) }) - } - // MARK: -- updates the cache with the default rooms it("updates the cache with the default rooms") { RetrieveDefaultOpenGroupRoomsJob.run( diff --git a/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift index bd1b3c8a59..cb9c4aff7a 100644 --- a/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/CommunityManagerSpec.swift @@ -67,37 +67,35 @@ class CommunityManagerSpec: AsyncSpec { activeUsers: 10, details: .mock ) - @TestState var testMessage: Network.SOGS.Message! = Network.SOGS.Message( - id: 127, - sender: "05\(TestConstants.publicKey)", - posted: 123, - edited: nil, - deleted: nil, - seqNo: 124, - whisper: false, - whisperMods: false, - whisperTo: nil, - base64EncodedData: [ - "Cg0KC1Rlc3RNZXNzYWdlg", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AAAAAAAAAAAAAAAAAAAAA", - "AA" - ].joined(), - base64EncodedSignature: nil, - reactions: nil - ) + @TestState var testMessage: Network.SOGS.Message! = { + let proto = SNProtoContent.builder() + let protoDataBuilder = SNProtoDataMessage.builder() + proto.setSigTimestamp(1234567890000) + protoDataBuilder.setBody("TestMessage") + protoDataBuilder.setTimestamp(1234567890000) + proto.setDataMessage(try! protoDataBuilder.build()) + + return Network.SOGS.Message( + id: 127, + sender: "05\(TestConstants.publicKey)", + posted: 1234567890, + edited: nil, + deleted: nil, + seqNo: 124, + whisper: false, + whisperMods: false, + whisperTo: nil, + base64EncodedData: try! proto.build().serializedData().base64EncodedString(), + base64EncodedSignature: nil, + reactions: nil + ) + }() @TestState var testDirectMessage: Network.SOGS.DirectMessage! = { let proto = SNProtoContent.builder() let protoDataBuilder = SNProtoDataMessage.builder() proto.setSigTimestamp(1234567890000) protoDataBuilder.setBody("TestMessage") + protoDataBuilder.setTimestamp(1234567890000) proto.setDataMessage(try! protoDataBuilder.build()) return Network.SOGS.DirectMessage( @@ -275,8 +273,8 @@ class CommunityManagerSpec: AsyncSpec { @TestState var communityManager: CommunityManager! = CommunityManager(using: dependencies) - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { beforeEach { _ = userGroupsInitResult } @@ -349,8 +347,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: -- when checking if an open group is run by session - context("when checking if an open group is run by session") { + // MARK: -- when checking if an community is run by session + context("when checking if an community is run by session") { // MARK: ---- returns false when it does not match one of Sessions servers with no scheme it("returns false when it does not match one of Sessions servers with no scheme") { expect(CommunityManager.isSessionRunCommunity(server: "test.test")) @@ -418,195 +416,187 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: -- when checking it has an existing open group - context("when checking it has an existing open group") { - // MARK: ---- when there is a thread for the room and the cache has a poller - context("when there is a thread for the room and the cache has a poller") { + // MARK: -- when checking it has an existing community + context("when checking it has an existing community") { + // MARK: ---- for the no-scheme variant + context("for the no-scheme variant") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + await communityManager.updateServer(server: CommunityManager.Server( + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) } - // MARK: ------ for the no-scheme variant - context("for the no-scheme variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } - // MARK: ------ for the http variant - context("for the http variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } - // MARK: ------ for the https variant - context("for the https variant") { - // MARK: -------- returns true when no scheme is provided - it("returns true when no scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a http scheme is provided - it("returns true when a http scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } - - // MARK: -------- returns true when a https scheme is provided - it("returns true when a https scheme is provided") { - expect( - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - ).to(beTrue()) - } + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) } } - // MARK: ---- when given the legacy DNS host and there is a cached poller for the default server - context("when given the legacy DNS host and there is a cached poller for the default server") { - // MARK: ------ returns true - it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://116.203.70.33"]) - mockStorage.write { db in - try SessionThread( - id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), - variant: .community, - creationDateTimestamp: 0, - shouldBeVisible: true, - isPinned: false, - messageDraft: nil, - notificationSound: nil, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false - ).insert(db) - } - + // MARK: ---- for the http variant + context("for the http variant") { + beforeEach { + await communityManager.updateServer(server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + } + + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://open.getsession.org", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } - // MARK: ---- when given the default server and there is a cached poller for the legacy DNS host - context("when given the default server and there is a cached poller for the legacy DNS host") { - // MARK: ------ returns true - it("returns true") { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://open.getsession.org"]) - mockStorage.write { db in - try SessionThread( - id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), - variant: .community, - creationDateTimestamp: 0, - shouldBeVisible: true, - isPinned: false, - messageDraft: nil, - notificationSound: nil, - mutedUntilTimestamp: nil, - onlyNotifyForMentions: false - ).insert(db) - } - + // MARK: ---- for the https variant + context("for the https variant") { + beforeEach { + await communityManager.updateServer(server: CommunityManager.Server( + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + } + + // MARK: ------ returns true when no scheme is provided + it("returns true when no scheme is provided") { expect( - mockStorage.read { db -> Bool in - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://116.203.70.33", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a http scheme is provided + it("returns true when a http scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ------ returns true when a https scheme is provided + it("returns true when a https scheme is provided") { + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "https://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beTrue()) } } + // MARK: ---- returns true when given the legacy DNS host and the cache includes the default server + it("returns true when given the legacy DNS host and the cache includes the default server") { + await communityManager.updateServer(server: CommunityManager.Server( + server: Network.SOGS.defaultServer, + publicKey: Network.SOGS.defaultServerPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://116.203.70.33", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + + // MARK: ---- returns true when given the default server and the legacy DNS host is cached + it("returns true when given the default server and the legacy DNS host is cached") { + await communityManager.updateServer(server: CommunityManager.Server( + server: Network.SOGS.legacyDefaultServerIP, + publicKey: Network.SOGS.defaultServerPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + + expect( + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://open.getsession.org", + publicKey: TestConstants.serverPublicKey + ) + ).to(beTrue()) + } + // MARK: ---- returns false when given an invalid server it("returns false when given an invalid server") { expect( - mockStorage.read { db -> Bool in - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "%%%", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "%%%", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } @@ -615,13 +605,11 @@ class CommunityManagerSpec: AsyncSpec { mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn([]) expect( - mockStorage.read { db -> Bool in - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } @@ -632,20 +620,18 @@ class CommunityManagerSpec: AsyncSpec { } expect( - mockStorage.read { db -> Bool in - communityManager.hasExistingCommunity( - roomToken: "testRoom", - server: "http://127.0.0.1", - publicKey: TestConstants.serverPublicKey - ) - } + communityManager.hasExistingCommunity( + roomToken: "testRoom", + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey + ) ).to(beFalse()) } } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when adding context("when adding") { beforeEach { @@ -672,8 +658,8 @@ class CommunityManagerSpec: AsyncSpec { .thenReturn(Date(timeIntervalSince1970: 1234567890)) } - // MARK: ---- stores the open group server - it("stores the open group server") { + // MARK: ---- stores the community server + it("stores the community server") { mockStorage .writePublisher { db -> Bool in communityManager.add( @@ -746,7 +732,13 @@ class CommunityManagerSpec: AsyncSpec { // MARK: ---- an existing room context("an existing room") { beforeEach { - mockCommunityPollerCache.when { $0.serversBeingPolled }.thenReturn(["http://127.0.0.1"]) + await communityManager.updateServer(server: CommunityManager.Server( + server: "http://127.0.0.1", + publicKey: TestConstants.serverPublicKey, + openGroups: [testOpenGroup], + using: dependencies + )) + mockStorage.write { db in try testOpenGroup.insert(db) } @@ -900,8 +892,8 @@ class CommunityManagerSpec: AsyncSpec { .to(equal(0)) } - // MARK: ---- and there is only one open group for this server - context("and there is only one open group for this server") { + // MARK: ---- and there is only one community for this server + context("and there is only one community for this server") { // MARK: ------ stops the poller it("stops the poller") { mockStorage.write { db in @@ -916,8 +908,8 @@ class CommunityManagerSpec: AsyncSpec { .to(call(matchingParameters: .all) { $0.stopAndRemovePoller(for: "http://127.0.0.1") }) } - // MARK: ------ removes the open group - it("removes the open group") { + // MARK: ------ removes the community + it("removes the community") { mockStorage.write { db in try communityManager.delete( db, @@ -931,8 +923,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: ---- and the are multiple open groups for this server - context("and the are multiple open groups for this server") { + // MARK: ---- and the are multiple communities for this server + context("and the are multiple communities for this server") { beforeEach { mockStorage.write { db in try OpenGroup.deleteAll(db) @@ -954,8 +946,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: ------ removes the open group - it("removes the open group") { + // MARK: ------ removes the community + it("removes the community") { mockStorage.write { db in try communityManager.delete( db, @@ -968,78 +960,6 @@ class CommunityManagerSpec: AsyncSpec { .to(equal(1)) } } - - // MARK: ---- and it is the default server - context("and it is the default server") { - beforeEach { - mockStorage.write { db in - try OpenGroup.deleteAll(db) - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom", - publicKey: TestConstants.publicKey, - shouldPoll: true, - name: "Test1", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - try OpenGroup( - server: Network.SOGS.defaultServer, - roomToken: "testRoom1", - publicKey: TestConstants.publicKey, - shouldPoll: true, - name: "Test1", - roomDescription: nil, - imageId: nil, - userCount: 0, - infoUpdates: 0, - sequenceNumber: 0, - inboxLatestMessageId: 0, - outboxLatestMessageId: 0 - ).insert(db) - } - } - - // MARK: ------ does not remove the open group - it("does not remove the open group") { - mockStorage.write { db in - try communityManager.delete( - db, - openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), - skipLibSessionUpdate: true - ) - } - - expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) - .to(equal(2)) - } - - // MARK: ------ deactivates the open group - it("deactivates the open group") { - mockStorage.write { db in - try communityManager.delete( - db, - openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer), - skipLibSessionUpdate: true - ) - } - - expect( - mockStorage.read { db in - try OpenGroup - .select(.shouldPoll) - .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: Network.SOGS.defaultServer)) - .asRequest(of: Bool.self) - .fetchOne(db) - } - ).to(beFalse()) - } - } } // MARK: -- when handling capabilities @@ -1066,8 +986,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling room poll info context("when handling room poll info") { beforeEach { @@ -1078,8 +998,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: ---- saves the updated open group - it("saves the updated open group") { + // MARK: ---- saves the updated community + it("saves the updated community") { mockStorage.write { db in try communityManager.handlePollInfo( db, @@ -1415,8 +1335,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: ---- when it cannot get the open group - context("when it cannot get the open group") { + // MARK: ---- when it cannot get the community + context("when it cannot get the community") { // MARK: ------ does not save the thread it("does not save the thread") { mockStorage.write { db in @@ -1651,8 +1571,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling messages context("when handling messages") { beforeEach { @@ -1673,12 +1593,10 @@ class CommunityManagerSpec: AsyncSpec { } .thenReturn( DecodedMessage( - content: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + - Data([0x80]) + - Data([UInt8](repeating: 0, count: 32)), + content: Data(base64Encoded: testMessage.base64EncodedData!)!, sender: SessionId(.standard, hex: TestConstants.publicKey), decodedEnvelope: nil, - sentTimestampMs: 1234567890 + sentTimestampMs: 1234567890000 ) ) mockStorage.write { db in @@ -1781,8 +1699,24 @@ class CommunityManagerSpec: AsyncSpec { expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } - // MARK: ---- ignores a message with invalid data - it("ignores a message with invalid data") { + // MARK: ---- ignores a message which fails to decode + it("ignores a message which fails to decode") { + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in try Interaction.deleteWhere(db, .deleteAll) } @@ -1934,8 +1868,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when handling direct messages context("when handling direct messages") { beforeEach { @@ -1956,12 +1890,10 @@ class CommunityManagerSpec: AsyncSpec { } .thenReturn( DecodedMessage( - content: Data(base64Encoded:"Cg0KC1Rlc3RNZXNzYWdlcNCI7I/3Iw==")! + - Data([0x80]) + - Data([UInt8](repeating: 0, count: 32)), + content: Data(base64Encoded: testDirectMessage.base64EncodedMessage)!, sender: SessionId(.standard, hex: TestConstants.publicKey), decodedEnvelope: nil, - sentTimestampMs: 1234567890 + sentTimestampMs: 1234567890000 ) ) mockCrypto @@ -1999,8 +1931,8 @@ class CommunityManagerSpec: AsyncSpec { ).to(equal(0)) } - // MARK: ---- does nothing if it cannot get the open group - it("does nothing if it cannot get the open group") { + // MARK: ---- does nothing if it cannot get the community + it("does nothing if it cannot get the community") { mockStorage.write { db in try OpenGroup.deleteAll(db) } @@ -2033,8 +1965,8 @@ class CommunityManagerSpec: AsyncSpec { ).to(beNil()) } - // MARK: ---- ignores messages with non base64 encoded data - it("ignores messages with non base64 encoded data") { + // MARK: ---- ignores messages which fail to decode + it("ignores messages which fail to decode") { testDirectMessage = Network.SOGS.DirectMessage( id: testDirectMessage.id, sender: testDirectMessage.sender.replacingOccurrences(of: "8", with: "9"), @@ -2043,6 +1975,22 @@ class CommunityManagerSpec: AsyncSpec { expires: testDirectMessage.expires, base64EncodedMessage: "TestMessage%%%" ) + mockCrypto + .when { + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) + ) + ) + } + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in communityManager.handleDirectMessages( @@ -2109,7 +2057,7 @@ class CommunityManagerSpec: AsyncSpec { content: Data("TestInvalid".bytes), sender: SessionId(.standard, hex: TestConstants.publicKey), decodedEnvelope: nil, - sentTimestampMs: 1234567890 + sentTimestampMs: 1234567890000 ) ) @@ -2251,23 +2199,24 @@ class CommunityManagerSpec: AsyncSpec { ).toNot(beNil()) } - // MARK: ------ ignores a message with invalid data - it("ignores a message with invalid data") { + // MARK: ------ ignores a messages which fail to decode + it("ignores a messages which fail to decode") { mockCrypto .when { - $0.generate( - .plaintextWithSessionBlindingProtocol( - ciphertext: Array.any, - senderId: .any, - recipientId: .any, - serverPublicKey: .any + try $0.generate( + .decodedMessage( + encodedMessage: Data.any, + origin: .swarm( + publicKey: .any, + namespace: .default, + serverHash: .any, + serverTimestampMs: .any, + serverExpirationTimestamp: .any + ) ) ) } - .thenReturn(( - plaintext: Data("TestInvalid".bytes), - senderSessionIdHex: "05\(TestConstants.publicKey)" - )) + .thenThrow(MessageError.invalidMessage("Test")) mockStorage.write { db in communityManager.handleDirectMessages( @@ -2325,8 +2274,8 @@ class CommunityManagerSpec: AsyncSpec { } } - // MARK: - an OpenGroupManager - describe("an OpenGroupManager") { + // MARK: - a CommunityManager + describe("a CommunityManager") { // MARK: -- when determining if a user is a moderator or an admin context("when determining if a user is a moderator or an admin") { beforeEach { @@ -2391,7 +2340,7 @@ class CommunityManagerSpec: AsyncSpec { await expect { await communityManager.isUserModeratorOrAdmin( - targetUserPublicKey: "05\(TestConstants.publicKey)", + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", server: "http://127.0.0.1", roomToken: "testRoom", includingHidden: true @@ -2424,7 +2373,7 @@ class CommunityManagerSpec: AsyncSpec { await expect { await communityManager.isUserModeratorOrAdmin( - targetUserPublicKey: "05\(TestConstants.publicKey)", + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", server: "http://127.0.0.1", roomToken: "testRoom", includingHidden: true @@ -2457,7 +2406,7 @@ class CommunityManagerSpec: AsyncSpec { await expect { await communityManager.isUserModeratorOrAdmin( - targetUserPublicKey: "05\(TestConstants.publicKey)", + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", server: "http://127.0.0.1", roomToken: "testRoom", includingHidden: true @@ -2490,7 +2439,7 @@ class CommunityManagerSpec: AsyncSpec { await expect { await communityManager.isUserModeratorOrAdmin( - targetUserPublicKey: "05\(TestConstants.publicKey)", + targetUserPublicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", server: "http://127.0.0.1", roomToken: "testRoom", includingHidden: true @@ -2513,7 +2462,8 @@ class CommunityManagerSpec: AsyncSpec { // MARK: ---- and the key belongs to the current user context("and the key belongs to the current user") { // MARK: ------ matches a blinded key - it("matches a blinded key ") { + it("matches a blinded key") { + mockCrypto.removeMocksFor { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } mockCrypto .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn( @@ -2590,8 +2540,8 @@ class CommunityManagerSpec: AsyncSpec { } await communityManager.fetchDefaultRoomsIfNeeded() - expect(mockNetwork) - .to(call { network in + await expect(mockNetwork) + .toEventually(call { network in network.send( endpoint: Network.SOGS.Endpoint.sequence, destination: expectedRequest.destination, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index a0dff46dca..c200dfea5e 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -257,7 +257,12 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- Messages @TestState var decodedMessage: DecodedMessage! = DecodedMessage( - content: Data([1, 2, 3]), + content: Data( + base64Encoded: "CAESvwEKABIAGrYBCAYSACjQiOyP9yM4AUKmAfjX/WXVFs+QE5Eh54Esw9/N" + + "lYza3k8MOvcRAI7y8k0JzLsm/KpXxKP7Zx7+5YyII9sCRXzFK2U4/X9SSMN088YEr/5wKoDfL5q" + + "PQbN70aa59WS8YE+yWcniQO0KXfAzr6Acn40fsa9BMr9tnQLfvxY8vD7qBz9iEOV9jTxPzxUoD+" + + "JelIbsv2qlkOl9vs166NC/Y772NZmUAR5u1ewL4SYEWkqX5R4gAA==" + )!, sender: SessionId(.standard, hex: "1111111111111111111111111111111111111111111111111111111111111111"), decodedEnvelope: nil, sentTimestampMs: 1234567890000 @@ -978,6 +983,9 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -------- subscribes for push notifications it("subscribes for push notifications") { + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) let expectedRequest: Network.PreparedRequest = mockStorage.write { db in _ = try SessionThread.upsert( db, @@ -1149,7 +1157,9 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: ---- fails if it cannot convert the group seed to a groupIdentityKeyPair it("fails if it cannot convert the group seed to a groupIdentityKeyPair") { - mockCrypto.when { $0.generate(.ed25519KeyPair(seed: Array.any)) }.thenReturn(nil) + mockCrypto + .when { try $0.tryGenerate(.ed25519KeyPair(seed: Array.any)) } + .thenThrow(TestError.mock) mockStorage.write { db in expect { @@ -1164,7 +1174,7 @@ class MessageReceiverGroupsSpec: QuickSpec { currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage("Test"))) + }.to(throwError(TestError.mock)) } } @@ -1234,6 +1244,16 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving an info changed message context("when receiving an info changed message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: infoChangedMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -1248,48 +1268,6 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - infoChangedMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: infoChangedMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - infoChangedMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: infoChangedMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { mockCrypto @@ -1352,6 +1330,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" infoChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) } // MARK: ------ creates the correct control message @@ -1391,6 +1378,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) infoChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" infoChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) } // MARK: ------ creates the correct control message @@ -1431,6 +1427,16 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member changed message context("when receiving a member changed message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberChangedMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -1445,48 +1451,6 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - memberChangedMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberChangedMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - memberChangedMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberChangedMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - // MARK: ---- throws if the admin signature fails to verify it("throws if the admin signature fails to verify") { mockCrypto @@ -1647,6 +1611,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1690,6 +1663,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1727,6 +1709,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1765,6 +1756,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1804,6 +1804,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1841,6 +1850,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1879,6 +1897,15 @@ class MessageReceiverGroupsSpec: QuickSpec { ) memberChangedMessage.sender = "051111111111111111111111111111111111111111111111111111111111111111" memberChangedMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111111" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) mockStorage.write { db in try MessageReceiver.handleGroupUpdateMessage( @@ -1942,30 +1969,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(interactions).to(beEmpty()) } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - memberLeftMessage.sender = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: memberLeftMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - memberLeftMessage.sentTimestampMs = nil + // MARK: ---- throws if the current user is not an admin + it("throws if the current user is not an admin") { + mockLibSessionCache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(false) mockStorage.write { db in expect { @@ -1980,7 +1986,7 @@ class MessageReceiverGroupsSpec: QuickSpec { currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage("Test"))) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -1994,6 +2000,13 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.set(\.name, to: "TestOtherName") groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: memberLeftMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberLeftMessage.sentTimestampMs! + ) + mockStorage.write { db in try ClosedGroup( threadId: groupId.hexString, @@ -2141,6 +2154,13 @@ class MessageReceiverGroupsSpec: QuickSpec { // MARK: -- when receiving a member left notification message context("when receiving a member left notification message") { beforeEach { + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: memberLeftNotificationMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: memberLeftNotificationMessage.sentTimestampMs! + ) + mockStorage.write { db in try SessionThread.upsert( db, @@ -2238,9 +2258,9 @@ class MessageReceiverGroupsSpec: QuickSpec { } } - // MARK: ---- throws if there is no sender - it("throws if there is no sender") { - inviteResponseMessage.sender = nil + // MARK: ---- throws if the message isn't an approval + it("throws if the message isn't an approval") { + inviteResponseMessage.isApproved = false mockStorage.write { db in expect { @@ -2255,28 +2275,7 @@ class MessageReceiverGroupsSpec: QuickSpec { currentUserSessionIds: [], using: dependencies ) - }.to(throwError(MessageError.invalidMessage("Test"))) - } - } - - // MARK: ---- throws if there is no timestamp - it("throws if there is no timestamp") { - inviteResponseMessage.sentTimestampMs = nil - - mockStorage.write { db in - expect { - try MessageReceiver.handleGroupUpdateMessage( - db, - threadId: groupId.hexString, - threadVariant: .group, - message: inviteResponseMessage, - decodedMessage: decodedMessage, - serverExpirationTimestamp: 1234567890, - suppressNotifications: false, - currentUserSessionIds: [], - using: dependencies - ) - }.to(throwError(MessageError.invalidMessage("Test"))) + }.to(throwError(MessageError.ignorableMessage)) } } @@ -2315,6 +2314,13 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.invited = 1 groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: inviteResponseMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: inviteResponseMessage.sentTimestampMs! + ) + mockStorage.write { db in try ClosedGroup( threadId: groupId.hexString, @@ -2999,6 +3005,18 @@ class MessageReceiverGroupsSpec: QuickSpec { ) deleteContentMessage.sender = "051111111111111111111111111111111111111111111111111111111111111112" deleteContentMessage.sentTimestampMs = 1234567800000 + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: SessionId( + .standard, + hex: "1111111111111111111111111111111111111111111111111111111111111112" + ), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: 1234567800000 + ) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) let preparedRequest: Network.PreparedRequest<[String: Bool]> = try! Network.SnodeAPI .preparedDeleteMessages( @@ -3282,6 +3300,9 @@ class MessageReceiverGroupsSpec: QuickSpec { mockUserDefaults .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: nil, authData: Data([1, 2, 3]))) let expectedRequest: Network.PreparedRequest = mockStorage.read { db in try Network.PushNotification.preparedUnsubscribe( @@ -3505,7 +3526,7 @@ class MessageReceiverGroupsSpec: QuickSpec { using: dependencies ) } - .to(throwError(MessageError.invalidMessage("Test"))) + .to(throwError(MessageError.ignorableMessage)) } } @@ -3541,6 +3562,16 @@ class MessageReceiverGroupsSpec: QuickSpec { groupMember.invited = 1 groups_members_set(groupMembersConf, &groupMember) + decodedMessage = DecodedMessage( + content: decodedMessage.content, + sender: try! SessionId(from: visibleMessage.sender!), + decodedEnvelope: decodedMessage.decodedEnvelope, + sentTimestampMs: visibleMessage.sentTimestampMs! + ) + mockLibSessionCache + .when { $0.authData(groupSessionId: .any) } + .thenReturn(GroupAuthData(groupIdentityPrivateKey: Data([1, 2, 3]), authData: nil)) + mockStorage.write { db in try SessionThread.upsert( db, From 83e4a7497e77e969678847590474eedeec314029 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 13:46:33 +1100 Subject: [PATCH 49/66] Attempt to fix CI build issue --- .../SessionProPaymentScreen.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift index 8474773f29..169a33c388 100644 --- a/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionProSettings/SessionProPaymentScreen.swift @@ -202,9 +202,18 @@ public struct SessionProPaymentScreen: View { originatingPlatform: originatingPlatform, openProRoadmapAction: { openUrl(SNUIKit.urlStringProvider().proRoadmap) } ) + + case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: true, let requestedAt): + RequestRefundNonOriginatorContent( + originatingPlatform: .iOS, + isNonOriginatingAccount: true, + requestedAt: requestedAt, + openPlatformStoreWebsiteAction: { + openUrl(SNUIKit.proClientPlatformStringProvider(for: .iOS).updateSubscriptionUrl) + } + ) - case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: false, _), - .refund(originatingPlatform: .iOS, isNonOriginatingAccount: .none, _): + case .refund(originatingPlatform: .iOS, _, _): RequestRefundOriginatingPlatformContent( requestRefundAction: { Task { @MainActor [weak viewModel] in @@ -219,16 +228,6 @@ public struct SessionProPaymentScreen: View { } ) - case .refund(originatingPlatform: .iOS, isNonOriginatingAccount: true, let requestedAt): - RequestRefundNonOriginatorContent( - originatingPlatform: .iOS, - isNonOriginatingAccount: true, - requestedAt: requestedAt, - openPlatformStoreWebsiteAction: { - openUrl(SNUIKit.proClientPlatformStringProvider(for: .iOS).updateSubscriptionUrl) - } - ) - case .refund(originatingPlatform: .android, let isNonOriginatingAccount, let requestedAt): RequestRefundNonOriginatorContent( originatingPlatform: .android, From 32a22b5ba20e88fa57a488ca51c84b53cb09bd30 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 13:58:26 +1100 Subject: [PATCH 50/66] CI build error --- SessionNetworkingKit/SessionPro/SessionProAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionNetworkingKit/SessionPro/SessionProAPI.swift b/SessionNetworkingKit/SessionPro/SessionProAPI.swift index 5c326f21ff..a3279f3758 100644 --- a/SessionNetworkingKit/SessionPro/SessionProAPI.swift +++ b/SessionNetworkingKit/SessionPro/SessionProAPI.swift @@ -250,7 +250,7 @@ public extension Network.SessionPro { provider: .appStore, paymentId: transactionId, orderId: "" /// The `order_id` is only needed for Google transactions - ), + ) ), using: dependencies ), From 282701f7e0365bb9dcfd70a4de7fab3a6e4da808 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 18 Dec 2025 14:12:46 +1100 Subject: [PATCH 51/66] More CI errors, added code to update the `accessExpiryTsMs` in the config --- .../LibSession+UserProfile.swift | 14 +++++++ .../LibSession+SessionMessagingKit.swift | 5 ++- .../Open Groups/CommunityManager.swift | 4 +- .../SessionPro/SessionProManager.swift | 37 ++++++++++++++++++- .../Types/MessageViewModel.swift | 2 +- .../_TestUtilities/MockLibSessionCache.swift | 4 ++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 0823951576..9e5d109d7d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -157,6 +157,14 @@ internal extension LibSessionCacheType { db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) } + /// If the `proAccessExpiryTimestampMs` value was updated then we need to take the larger of the two + let oldProAccessExpiryTimestampMs: UInt64 = (oldState[.proAccessExpiryUpdated] as? UInt64 ?? 0) + let proAccessExpiryTimestampMs: UInt64 = user_profile_get_pro_access_expiry_ms(conf) + + if oldProAccessExpiryTimestampMs > proAccessExpiryTimestampMs { + updateProAccessExpiryTimestampMs(oldProAccessExpiryTimestampMs) + } + // Update the SessionProManager with these changes db.afterCommit { [sessionProManager = dependencies[singleton: .sessionProManager]] in Task { await sessionProManager.updateWithLatestFromUserConfig() } @@ -326,6 +334,12 @@ public extension LibSession.Cache { user_profile_remove_pro_config(conf) } + + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) { + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { return } + + user_profile_set_pro_access_expiry_ms(conf, proAccessExpiryTimestampMs) + } } // MARK: - ProfileInfo diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 838d6062b5..73fa08dba9 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -829,7 +829,8 @@ public extension LibSession { case .userProfile: result[variant] = [ .profile(userSessionId.hexString): profile, - .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests) + .setting(.checkForCommunityMessageRequests): get(.checkForCommunityMessageRequests), + .proAccessExpiryUpdated: proAccessExpiryTimestampMs ] case .contacts(let conf): @@ -1141,6 +1142,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT ) throws func updateProConfig(proConfig: SessionPro.ProConfig) func removeProConfig() + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) func canPerformChange( threadId: String, @@ -1428,6 +1430,7 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { ) throws {} func updateProConfig(proConfig: SessionPro.ProConfig) {} func removeProConfig() {} + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) {} func canPerformChange( threadId: String, diff --git a/SessionMessagingKit/Open Groups/CommunityManager.swift b/SessionMessagingKit/Open Groups/CommunityManager.swift index 58933aaf88..7c8943ce9b 100644 --- a/SessionMessagingKit/Open Groups/CommunityManager.swift +++ b/SessionMessagingKit/Open Groups/CommunityManager.swift @@ -458,7 +458,7 @@ public actor CommunityManager: CommunityManagerType { pollInfo: Network.SOGS.RoomPollInfo(room: response.value.room.data), server: targetServer, roomToken: roomToken, - publicKey: publicKey, + publicKey: publicKey ) } .handleEvents( @@ -1315,7 +1315,7 @@ private final class CommunityManagerSyncState { fileprivate func update( servers: Update<[String: CommunityManager.Server]> = .useExisting, pendingChanges: Update<[CommunityManager.PendingChange]> = .useExisting, - lastSuccessfulCommunityPollTimestamp: Update = .useExisting, + lastSuccessfulCommunityPollTimestamp: Update = .useExisting ) { lock.withLock { self._servers = servers.or(self._servers) diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 3fc1a62fd8..9b9eb76358 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -368,6 +368,11 @@ public actor SessionProManager: SessionProManagerType { /// other device did something that should refresh the pro state if updatedState.accessExpiryTimestampMs != oldState.accessExpiryTimestampMs { try? await refreshProState() + + await dependencies.notify( + key: .proAccessExpiryUpdated, + value: proInfo.accessExpiryTimestampMs + ) } } @@ -585,8 +590,29 @@ public actor SessionProManager: SessionProManagerType { status: updatedState.status ) - case .neverBeenPro: try await clearProProofFromConfig() - case .expired: try await clearProProofFromConfig() + case .neverBeenPro: + try await clearProProofFromConfig() + + /// We should still update the `accessExpiryTimestampMs` stored in the config just in case + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProAccessExpiryTimestampMs(updatedState.accessExpiryTimestampMs ?? 0) + } + } + } + + case .expired: + try await clearProProofFromConfig() + + /// We should still update the `accessExpiryTimestampMs` stored in the config just in case + try await dependencies[singleton: .storage].writeAsync { [dependencies] db in + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile) { _ in + cache.updateProAccessExpiryTimestampMs(updatedState.accessExpiryTimestampMs ?? 0) + } + } + } } updatedState = oldState.with( @@ -656,6 +682,7 @@ public actor SessionProManager: SessionProManagerType { proProof: response.proof ) ) + cache.updateProAccessExpiryTimestampMs(accessExpiryTimestampMs) } } } @@ -910,11 +937,17 @@ public extension ObservableKey { generic: .currentUserProState ) { [weak manager] in manager?.state } } + + static let proAccessExpiryUpdated: ObservableKey = ObservableKey( + "proAccessExpiryUpdated", + .proAccessExpiryUpdated + ) } // stringlint:ignore_contents public extension GenericObservableKey { static let currentUserProState: GenericObservableKey = "currentUserProState" + static let proAccessExpiryUpdated: GenericObservableKey = "proAccessExpiryUpdated" } // MARK: - Mocking diff --git a/SessionMessagingKit/Types/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift index d977a742af..7950120d6f 100644 --- a/SessionMessagingKit/Types/MessageViewModel.swift +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -545,7 +545,7 @@ public extension MessageViewModel { func with( state: Update = .useExisting, // Optimistic outgoing messages - mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages + mostRecentFailureText: Update = .useExisting // Optimistic outgoing messages ) -> MessageViewModel { return MessageViewModel( cellType: cellType, diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 0bbe0d8f3b..b852bfa187 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -177,6 +177,10 @@ class MockLibSessionCache: Mock, LibSessionCacheType { mockNoReturn() } + func updateProAccessExpiryTimestampMs(_ proAccessExpiryTimestampMs: UInt64) { + mockNoReturn(args: [proAccessExpiryTimestampMs]) + } + func canPerformChange( threadId: String, threadVariant: SessionThread.Variant, From b1ed813faf345d96d6dd0981846479336048fb88 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Dec 2025 08:42:37 +1100 Subject: [PATCH 52/66] Update libSession, some pro revocation list logic, fix warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added some initial pro revocation list logic • Updated to libSession 1.5.9 • Fixed duplicate file warnings --- Session.xcodeproj/project.pbxproj | 41 ++++++------- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../DeveloperSettingsProViewModel.swift | 61 +++++++++++++++++-- .../SessionPro/SessionProManager.swift | 54 +++++++++++++++- .../SessionPro/Types/SessionProError.swift | 2 + .../Utilities/Preferences.swift | 3 + .../SessionPro/Types/RevocationItem.swift | 2 +- 7 files changed, 133 insertions(+), 34 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a9044ff703..9dbf3f865a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -175,7 +175,6 @@ 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 94363E5B2E6002750004EE43 /* SessionListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5A2E60026F0004EE43 /* SessionListScreen.swift */; }; 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E5D2E6002940004EE43 /* SessionListScreen+Models.swift */; }; @@ -257,7 +256,6 @@ 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; @@ -520,6 +518,14 @@ FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; FD1D732E2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _029_BlockCommunityMessageRequests.swift */; }; + FD1DD8B52EF3ACBA009F2C1B /* LinkPreviewManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */; }; + FD1DD8B62EF3ACC9009F2C1B /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; + FD1DD8B72EF3ACCF009F2C1B /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; + FD1DD8B82EF3ACDF009F2C1B /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; + FD1DD8B92EF3ACE5009F2C1B /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; + FD1DD8BA2EF3ACF5009F2C1B /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; }; + FD1DD8BB2EF3AD04009F2C1B /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; + FD1DD8BC2EF3AD0C009F2C1B /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */; }; FD1F3CEB2ED5728100E536D5 /* SetPaymentRefundRequestedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEA2ED5728000E536D5 /* SetPaymentRefundRequestedRequest.swift */; }; FD1F3CED2ED5728600E536D5 /* SetPaymentRefundRequestedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEC2ED5728300E536D5 /* SetPaymentRefundRequestedResponse.swift */; }; FD1F3CEF2ED6509900E536D5 /* SessionProUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F3CEE2ED6509600E536D5 /* SessionProUI.swift */; }; @@ -717,7 +723,6 @@ FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D428A1FCE8003AE748 /* Theme+OceanLight.swift */; }; FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D628A20B5D003AE748 /* UIColor+Utilities.swift */; }; FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */; }; - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DA28A244E9003AE748 /* ThemeMessagePreviewView.swift */; }; FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9DC28A384EB003AE748 /* PrimaryColorSelectionView.swift */; }; FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9F528A5F106003AE748 /* Configuration.swift */; }; FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9FE28A5F2CD003AE748 /* Configuration.swift */; }; @@ -1041,7 +1046,6 @@ FD9E26C92EA72DC200404C7F /* SessionUIKit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = FD9E26C82EA72DC200404C7F /* SessionUIKit.xctestplan */; }; FD9E26CB2EA72E2600404C7F /* Quick in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CA2EA72E2600404C7F /* Quick */; }; FD9E26CD2EA72E2600404C7F /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = FD9E26CC2EA72E2600404C7F /* Nimble */; }; - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26CF2EA73F4800404C7F /* UTType+Localization.swift */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; @@ -1051,14 +1055,11 @@ FDAA36A92EB2C3E50040603E /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; }; FDAA36AB2EB2C45E0040603E /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */; }; FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC0F0032BFECE12002CBFB9 /* TimeUnit.swift */; }; - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; }; FDAA36AF2EB2C6EE0040603E /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; FDAA36B22EB2D2F60040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B12EB2D2F60040603E /* NVActivityIndicatorView */; }; FDAA36B42EB2DFA30040603E /* NVActivityIndicatorView in Frameworks */ = {isa = PBXBuildFile; productRef = FDAA36B32EB2DFA30040603E /* NVActivityIndicatorView */; }; FDAA36B72EB2E55C0040603E /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36B82EB3FBC20040603E /* LinkPreviewManagerType.swift */; }; FDAA36BC2EB3FC980040603E /* LinkPreviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BB2EB3FC940040603E /* LinkPreviewManager.swift */; }; FDAA36BE2EB3FFB50040603E /* Task+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BD2EB3FFB10040603E /* Task+Utilities.swift */; }; FDAA36C02EB435950040603E /* SessionProUIManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA36BF2EB435910040603E /* SessionProUIManagerType.swift */; }; @@ -1087,7 +1088,6 @@ FDB348892BE8705D00B716C2 /* SessionUtilitiesKit.h in Headers */ = {isa = PBXBuildFile; fileRef = FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; FDB3DA842E1CA22400148F8D /* UIActivityViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */; }; FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA852E1E1F0B00148F8D /* TaskCancellation.swift */; }; - FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA872E24810900148F8D /* SessionAsyncImage.swift */; }; FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3DA892E2482A400148F8D /* AVURLAsset+Utilities.swift */; }; FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */; }; FDB5DAC12A9443A5002C8721 /* MessageSender+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB5DAC02A9443A5002C8721 /* MessageSender+Groups.swift */; }; @@ -6777,19 +6777,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 948615C52ED7D4D4000A5666 /* ModalActivityIndicatorViewController.swift in Sources */, 9438D51A2E6951B3008C7FFE /* AnimatedToggle.swift in Sources */, - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, - FDAA36AC2EB2C5840040603E /* VoiceMessageRecordingView.swift in Sources */, - FD9E26CE2EA72EFF00404C7F /* QuoteView_SwiftUI.swift in Sources */, - FDAA36B92EB3FBC80040603E /* LinkPreviewManagerType.swift in Sources */, - 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, + FD1DD8B72EF3ACCF009F2C1B /* QuoteView_SwiftUI.swift in Sources */, 9438D5572E6A6869008C7FFE /* SessionProPaymentScreen.swift in Sources */, 943B43642EC3FDC0008ABC34 /* ListItemAccessory+LoadingIndicator.swift in Sources */, FD8A5B022DBEFF73004C689B /* SessionNetworkScreen+Models.swift in Sources */, @@ -6832,6 +6825,7 @@ 9438658F2EAB380700DB989A /* MutipleLinksModal.swift in Sources */, 948615C92ED7D646000A5666 /* Publisher+Utilities.swift in Sources */, 94363E5E2E6002960004EE43 /* SessionListScreen+Models.swift in Sources */, + FD1DD8BC2EF3AD0C009F2C1B /* SessionAsyncImage.swift in Sources */, 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, @@ -6844,9 +6838,10 @@ FD1F3CFC2ED7F37600E536D5 /* StringProviders.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, + FD1DD8B82EF3ACDF009F2C1B /* AttributedLabel.swift in Sources */, 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, - FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, + FD1DD8B62EF3ACC9009F2C1B /* VoiceMessageRecordingView.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, 948615CB2ED7D6E5000A5666 /* NavigatableState.swift in Sources */, FDE754BA2C9B97B8002A2623 /* UIDevice+Utilities.swift in Sources */, @@ -6865,6 +6860,7 @@ 94805EBF2EB462C40055EBBC /* TransitionType.swift in Sources */, 943B43602EC3FCD6008ABC34 /* ListItemAccessory+Icon.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, + FD1DD8BB2EF3AD04009F2C1B /* TimeInterval+Utilities.swift in Sources */, 943B43562EC2AFAC008ABC34 /* SessionListScreen+ListItemDataMatrix.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, FDAA36AD2EB2C61D0040603E /* TimeUnit.swift in Sources */, @@ -6906,10 +6902,6 @@ 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, - FDAA36AE2EB2C6420040603E /* TimeInterval+Utilities.swift in Sources */, - 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, FDAA36AA2EB2C4550040603E /* ReusableView.swift in Sources */, FD9E26D02EA73F4E00404C7F /* UTType+Localization.swift in Sources */, @@ -6924,7 +6916,9 @@ FD16AB5F2A1DD98F0083D849 /* ProfilePictureView.swift in Sources */, C331FFE42558FB0000070591 /* SessionButton.swift in Sources */, C331FFE92558FB0000070591 /* Separator.swift in Sources */, + FD1DD8B92EF3ACE5009F2C1B /* SRCopyableLabel.swift in Sources */, FD71163228E2C42A00B47552 /* IconSize.swift in Sources */, + FD1DD8B52EF3ACBA009F2C1B /* LinkPreviewManagerType.swift in Sources */, C33100282559000A00070591 /* UIView+Utilities.swift in Sources */, FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */, 9463794C2E71371F0017A014 /* SessionProPaymentScreen+Models.swift in Sources */, @@ -7652,14 +7646,13 @@ 942256892C23F8C800C0FDBF /* LandingScreen.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, 942256882C23F8C800C0FDBF /* PNModeScreen.swift in Sources */, - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */, FD7443422D07A27E00862443 /* SyncPushTokensJob.swift in Sources */, - FD37E9DB28A244E9003AE748 /* ThemeMessagePreviewView.swift in Sources */, 7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, + FD1DD8BA2EF3ACF5009F2C1B /* ThemeMessagePreviewView.swift in Sources */, FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */, 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */, 7B13E1E92810F01300BD4F64 /* SessionCallManager+Action.swift in Sources */, @@ -11341,7 +11334,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.8; + version = 1.5.9; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b3897617b7..d507c27397 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "8101f907433df1e15d59dd031d7e1a9386f83bfc", - "version" : "1.5.8" + "revision" : "fa667ed2cc1e0633cff131c41746c59088dd9370", + "version" : "1.5.9" } }, { diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 72570203f2..570189f74f 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -92,6 +92,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case submitPurchaseToProBackend case refreshProState + case resetRevocationListTicket case removeProFromUserConfig // MARK: - Conformance @@ -122,6 +123,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .submitPurchaseToProBackend: return "submitPurchaseToProBackend" case .refreshProState: return "refreshProState" + case .resetRevocationListTicket: return "resetRevocationListTicket" case .removeProFromUserConfig: return "removeProFromUserConfig" } } @@ -155,6 +157,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .submitPurchaseToProBackend: result.append(.submitPurchaseToProBackend); fallthrough case .refreshProState: result.append(.refreshProState); fallthrough + case .resetRevocationListTicket: result.append(.resetRevocationListTicket); fallthrough case .removeProFromUserConfig: result.append(.removeProFromUserConfig) } @@ -199,6 +202,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let currentProStatus: String? let currentProStatusErrored: Bool + let currentRevocationListTicket: UInt32 @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( @@ -221,7 +225,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.forceMessageFeatureProBadge), .feature(.forceMessageFeatureLongMessage), .feature(.forceMessageFeatureAnimatedAvatar), - .updateScreen(DeveloperSettingsProViewModel.self) + .updateScreen(DeveloperSettingsProViewModel.self), + .proRevocationListUpdated ] static func initialState(using dependencies: Dependencies) -> State { @@ -252,7 +257,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold submittedTransactionErrored: false, currentProStatus: nil, - currentProStatusErrored: false + currentProStatusErrored: false, + currentRevocationListTicket: 0 ) } } @@ -276,10 +282,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus var submittedTransactionStatus: String? = previousState.submittedTransactionStatus var submittedTransactionErrored: Bool = previousState.submittedTransactionErrored + var currentRevocationListTicket: UInt32 = previousState.currentRevocationListTicket - events.forEach { event in - guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } - + if isInitialQuery { + currentRevocationListTicket = ((try? await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + }) ?? 0) + } + + let changes: EventChangeset = events.split() + + changes.forEach(.updateScreen, as: DeveloperSettingsProEvent.self) { eventValue in switch eventValue { case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let transaction): products = receivedProducts @@ -301,6 +314,12 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } + if changes.contains(.proRevocationListUpdated) { + currentRevocationListTicket = ((try? await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + }) ?? currentRevocationListTicket) + } + return State( sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionProBuildVariant: dependencies[feature: .mockCurrentUserSessionProBuildVariant], @@ -323,7 +342,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold submittedTransactionStatus: submittedTransactionStatus, submittedTransactionErrored: submittedTransactionErrored, currentProStatus: currentProStatus, - currentProStatusErrored: currentProStatusErrored + currentProStatusErrored: currentProStatusErrored, + currentRevocationListTicket: currentRevocationListTicket ) } @@ -749,6 +769,19 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Task { await viewModel?.refreshProState() } } ), + SessionCell.Info( + id: .resetRevocationListTicket, + title: "Reset Revocation List Ticket", + subtitle: """ + Reset the revocation list ticket (this will result in the revocation list being refetched from the beginning). + + Current Ticket: \(state.currentRevocationListTicket) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Reset"), + onTap: { [weak viewModel] in + Task { await viewModel?.resetProRevocationListTicket() } + } + ), SessionCell.Info( id: .removeProFromUserConfig, title: "Remove Pro From User Config", @@ -1014,6 +1047,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } } + private func resetProRevocationListTicket() async { + do { + try await dependencies[singleton: .storage].writeAsync { db in + db[.proRevocationsTicket] = nil + } + + await dependencies.notify( + key: .proRevocationListUpdated, + value: Array() + ) + } + catch { + Log.error("[DevSettings] Reset pro revocation list failed failed: \(error)") + } + } + private func removeProFromUserConfig() async { try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 9b9eb76358..27b1cd6f95 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -799,7 +799,53 @@ public actor SessionProManager: SessionProManagerType { private func startRevocationListTask() { revocationListTask = Task { - // TODO: [PRO] Need to add in the logic for fetching, storing and updating the revocation list + while true { + do { + let ticket: UInt32 = try await Result( + catching: { + try await dependencies[singleton: .storage].readAsync { db in + UInt32(db[.proRevocationsTicket] ?? 0) + } + } + ) + .mapError { SessionProError.getProRevocationsFailed("Could not retrieve ticket (\($0))") } + .get() + let request = try Network.SessionPro.getProRevocations( + ticket: ticket, + using: dependencies + ) + // FIXME: Make this async/await when the refactored networking is merged + let response: Network.SessionPro.GetProRevocationsResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }() + + guard response.header.errors.isEmpty else { + let errorString: String = response.header.errors.joined(separator: ", ") + throw SessionProError.getProRevocationsFailed(errorString) + } + + try await dependencies[singleton: .storage].writeAsync { db in + db[.proRevocationsTicket] = Int(response.ticket) + + // TODO: [PRO] Need to store the revocations in the database + } + + /// Send out a notification that the revocations list was updated, in case something wants to immediately respond + await dependencies.notify( + key: .proRevocationListUpdated, + value: response.items + ) + + Log.info(.sessionPro, (response.ticket != ticket ? "Successfully updated revocation list to \(response.ticket)." : "Revocation list already up-to-date.")) + try? await Task.sleep(for: .seconds(15 * 60)) /// Wait for 15 mins before trying again + } + catch { + Log.warn(.sessionPro, "\(error), will retry in 10s.") + try? await Task.sleep(for: .seconds(10)) + continue + } + } } } @@ -942,12 +988,18 @@ public extension ObservableKey { "proAccessExpiryUpdated", .proAccessExpiryUpdated ) + + static let proRevocationListUpdated: ObservableKey = ObservableKey( + "proRevocationListUpdated", + .proRevocationListUpdated + ) } // stringlint:ignore_contents public extension GenericObservableKey { static let currentUserProState: GenericObservableKey = "currentUserProState" static let proAccessExpiryUpdated: GenericObservableKey = "proAccessExpiryUpdated" + static let proRevocationListUpdated: GenericObservableKey = "proRevocationListUpdated" } // MARK: - Mocking diff --git a/SessionMessagingKit/SessionPro/Types/SessionProError.swift b/SessionMessagingKit/SessionPro/Types/SessionProError.swift index 78cd6b77c7..66ca9da736 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProError.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProError.swift @@ -14,6 +14,7 @@ public enum SessionProError: Error, CustomStringConvertible { case refundFailed(String) case generateProProofFailed(String) case getProDetailsFailed(String) + case getProRevocationsFailed(String) case noLatestPaymentItem case refundAlreadyRequestedForLatestPayment @@ -34,6 +35,7 @@ public enum SessionProError: Error, CustomStringConvertible { case .refundFailed(let error): return "The refund failed due to error: \(error)." case .generateProProofFailed(let error): return "Failed to generate the pro proof due to error: \(error)." case .getProDetailsFailed(let error): return "Failed to get pro details due to error: \(error)." + case .getProRevocationsFailed(let error): return "Failed to retrieve the latest pro revocations due to error: \(error)." case .noLatestPaymentItem: return "No latest payment item." case .refundAlreadyRequestedForLatestPayment: return "Refund already requested for latest payment" diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 3fd1d96279..fb0ed33abe 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -104,6 +104,9 @@ public extension KeyValueStore.IntKey { /// This is the number of times the app has successfully become active, it's not actually used for anything but allows us to make /// a database change on launch so the database will output an error if it fails to write static let activeCounter: KeyValueStore.IntKey = "activeCounter" + + /// This is the ticket number for the pro revocations request (it's used to to track the version of pro revocations the current device has) + static let proRevocationsTicket: KeyValueStore.IntKey = "proRevocationsTicket" } public enum Preferences { diff --git a/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift b/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift index e619b2acb3..de347ca258 100644 --- a/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift +++ b/SessionNetworkingKit/SessionPro/Types/RevocationItem.swift @@ -5,7 +5,7 @@ import SessionUtil import SessionUtilitiesKit public extension Network.SessionPro { - struct RevocationItem: Equatable { + struct RevocationItem: Sendable, Equatable, Hashable { public let genIndexHash: [UInt8] public let expiryUnixTimestampMs: UInt64 From 03a97678127d184ae0c358b62dcbb93dde645af1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Dec 2025 08:57:18 +1100 Subject: [PATCH 53/66] CI build errors -_- --- Session/Home/Message Requests/MessageRequestsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 2121052d4d..ec22ab5810 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -251,7 +251,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O viewState: (loadResult.info.totalCount == 0 ? .empty : .loaded), loadedPageInfo: loadResult.info, dataCache: dataCache, - itemCache: itemCache, + itemCache: itemCache ) } From 8c525d6c7265fd8240bdc9349aa478a7461282ef Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Dec 2025 12:09:01 +1100 Subject: [PATCH 54/66] Fixed bugs found during regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed a bug where an invalid thread would be created on fresh install • Fixed a bug where restoring a device could result in the conversation list staying empty --- Session/Home/HomeViewModel.swift | 1 + .../Migrations/_027_SessionUtilChanges.swift | 68 ++++++++++--------- .../SessionPro/SessionProManager.swift | 36 ++++------ .../SessionPro/Types/SessionProPlan.swift | 3 +- 4 files changed, 50 insertions(+), 58 deletions(-) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index ac6893dd1a..24fbdf8774 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -122,6 +122,7 @@ public class HomeViewModel: NavigatableStateHolder { .appLifecycle(.willEnterForeground), .databaseLifecycle(.resumed), .loadPage(HomeViewModel.self), + .conversationCreated, .messageRequestAccepted, .messageRequestDeleted, .messageRequestMessageRead, diff --git a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift index 57e76b93a9..73285e3fa6 100644 --- a/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_027_SessionUtilChanges.swift @@ -187,40 +187,42 @@ enum _027_SessionUtilChanges: Migration { /// **Note:** Since migrations are run when running tests creating a random SessionThread will result in unexpected thread /// counts so don't do this when running tests (this logic is the same as in `MainAppContext.isRunningTests` if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { - let threadExists: Bool? = try Bool.fetchOne( - db, - sql: "SELECT EXISTS (SELECT * FROM thread WHERE id = '\(userSessionId.hexString)')" - ) - - if threadExists == false { - try db.execute( - sql: """ - INSERT INTO thread ( - id, - variant, - creationDateTimestamp, - shouldBeVisible, - isPinned, - messageDraft, - notificationSound, - mutedUntilTimestamp, - onlyNotifyForMentions, - markedAsUnread, - pinnedPriority - ) - VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?) - """, - arguments: [ - userSessionId.hexString, - SessionThread.Variant.contact.rawValue, - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), - false, // Not visible - false, - false, - false, - -1, // Hidden priority at the time of writing - ] + if MigrationHelper.userExists(db) { + let threadExists: Bool? = try Bool.fetchOne( + db, + sql: "SELECT EXISTS (SELECT * FROM thread WHERE id = '\(userSessionId.hexString)')" ) + + if threadExists == false { + try db.execute( + sql: """ + INSERT INTO thread ( + id, + variant, + creationDateTimestamp, + shouldBeVisible, + isPinned, + messageDraft, + notificationSound, + mutedUntilTimestamp, + onlyNotifyForMentions, + markedAsUnread, + pinnedPriority + ) + VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?) + """, + arguments: [ + userSessionId.hexString, + SessionThread.Variant.contact.rawValue, + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + false, // Not visible + false, + false, + false, + -1, // Hidden priority at the time of writing + ] + ) + } } } diff --git a/SessionMessagingKit/SessionPro/SessionProManager.swift b/SessionMessagingKit/SessionPro/SessionProManager.swift index 27b1cd6f95..b94b7a65d8 100644 --- a/SessionMessagingKit/SessionPro/SessionProManager.swift +++ b/SessionMessagingKit/SessionPro/SessionProManager.swift @@ -581,6 +581,7 @@ public actor SessionProManager: SessionProManagerType { await self.stateStream.send(updatedState) oldState = updatedState + // TODO: [PRO] Make sure we _actually_ want to remove this state (doing so might mean that we can't tell that the user used to be pro) switch response.status { case .active: try await refreshProProofIfNeeded( @@ -590,29 +591,10 @@ public actor SessionProManager: SessionProManagerType { status: updatedState.status ) - case .neverBeenPro: - try await clearProProofFromConfig() - - /// We should still update the `accessExpiryTimestampMs` stored in the config just in case - try await dependencies[singleton: .storage].writeAsync { [dependencies] db in - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProAccessExpiryTimestampMs(updatedState.accessExpiryTimestampMs ?? 0) - } - } - } - - case .expired: - try await clearProProofFromConfig() - - /// We should still update the `accessExpiryTimestampMs` stored in the config just in case - try await dependencies[singleton: .storage].writeAsync { [dependencies] db in - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile) { _ in - cache.updateProAccessExpiryTimestampMs(updatedState.accessExpiryTimestampMs ?? 0) - } - } - } + case .neverBeenPro, .expired: + try await clearStateFromConfig( + accessExpiryTimestampMs: updatedState.accessExpiryTimestampMs + ) } updatedState = oldState.with( @@ -799,6 +781,8 @@ public actor SessionProManager: SessionProManagerType { private func startRevocationListTask() { revocationListTask = Task { + // TODO: [PRO] Load current revocation list into memory and add to `syncState` + while true { do { let ticket: UInt32 = try await Result( @@ -872,6 +856,7 @@ public actor SessionProManager: SessionProManagerType { } // TODO: [PRO] Do we want this to run in a loop with a sleep in case the user purchases pro on another device? + // TODO: [PRO] Could potentially kick off this task from `updateLatestFromUserConfig` if `updatedState.accessExpiryTimestampMs != oldState.accessExpiryTimestampMs`??? (would get triggered if a user purchased pro using the same account on a separate iOS device while the app is open on this one) entitlementsObservingTask = Task { [weak self] in guard let self else { return } @@ -895,11 +880,14 @@ public actor SessionProManager: SessionProManagerType { } } - private func clearProProofFromConfig() async throws { + private func clearStateFromConfig(accessExpiryTimestampMs: UInt64?) async throws { try await dependencies[singleton: .storage].writeAsync { [dependencies] db in try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile) { _ in cache.removeProConfig() + + /// We should also update the `accessExpiryTimestampMs` stored in the config just in case + cache.updateProAccessExpiryTimestampMs(accessExpiryTimestampMs ?? 0) } } } diff --git a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift index 856801904c..a8872b7512 100644 --- a/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift +++ b/SessionMessagingKit/SessionPro/Types/SessionProPlan.swift @@ -59,7 +59,7 @@ public extension SessionPro { ) ] ) -#endif +#else let products: [Product] = try await Product .products(for: productIds) .sorted() @@ -106,6 +106,7 @@ public extension SessionPro { } return (products, plans) +#endif } } } From 2622fae7faec1ba0b1e385369321e89579f1a2ab Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Dec 2025 16:08:13 +1100 Subject: [PATCH 55/66] Fixed a bunch of bugs found during regression testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed a possible deadlock during dependency initialisation • Fixed an issue preventing destroyed/kicked groups from rendering correctly • Fixed an auth issue when creating a new group • Fixed an issue where the input would incorrect allow text entry when the user can't send messages • Fixed an issue where conversations weren't correctly being identified as message requests • Fixed an issue where the empty state in a conversation could end up with the wrong font --- Session/Conversations/ConversationVC.swift | 8 +- .../Conversations/ConversationViewModel.swift | 88 ++++++++++++------- .../Settings/ThreadSettingsViewModel.swift | 28 +++--- Session/Home/HomeViewModel.swift | 28 +++--- .../MessageRequestsViewModel.swift | 28 +++--- Session/Shared/FullConversationCell.swift | 4 +- .../Jobs/ConfigurationSyncJob.swift | 21 +++-- .../MessageSender+Groups.swift | 4 + .../Types/ConversationInfoViewModel.swift | 82 ++++++++--------- .../Types/MessageViewModel.swift | 79 +++++++++++------ .../ThreadPickerViewModel.swift | 10 +-- .../Dependency Injection/Dependencies.swift | 46 ++++++---- 12 files changed, 247 insertions(+), 179 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7d323d15a4..4348f0172b 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -12,6 +12,7 @@ import SessionUtilitiesKit import SignalUtilitiesKit final class ConversationVC: BaseVC, LibSessionRespondingViewController, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { + private static let emptyStateLabelFont: UIFont = .systemFont(ofSize: Values.verySmallFontSize) private static let loadingHeaderHeight: CGFloat = 40 static let expandedAttachmentButtonSpacing: CGFloat = 4 @@ -262,7 +263,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa let result: UILabel = UILabel() result.accessibilityIdentifier = "Control message" result.translatesAutoresizingMaskIntoConstraints = false - result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.font = ConversationVC.emptyStateLabelFont result.themeAttributedText = viewModel.state.emptyStateText.formatted(in: result) result.themeTextColor = .textSecondary result.textAlignment = .center @@ -736,7 +737,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Update the table content let updatedSections: [ConversationViewModel.SectionModel] = state.sections(viewModel: viewModel) - // Update the empty state + /// Update the empty state + /// + /// **Note:** Need to reset the fonts as it seems that the `.font` values can end up using a styled font from the attributed text + emptyStateLabel.font = ConversationVC.emptyStateLabelFont emptyStateLabel.themeAttributedText = state.emptyStateText.formatted(in: emptyStateLabel) emptyStateLabelContainer.isHidden = (state.viewState != .empty) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 53e5de95f1..764e07d208 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -230,11 +230,33 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ) } - if threadInfo.variant == .community && !threadInfo.canWrite { - return InputView.InputState( - inputs: .disabled, - message: "permissionsWriteCommunity".localized() - ) + // TODO: [BUGFIXING] Need copy for these cases + guard threadInfo.canWrite else { + switch threadInfo.variant { + case .contact: + return InputView.InputState( + inputs: .disabled, + message: "You cannot send messages to this user." // TODO: [BUGFIXING] blocks community message requests or generic + ) + + case .group: + return InputView.InputState( + inputs: .disabled, + message: "You cannot send messages to this group." + ) + + case .legacyGroup: + return InputView.InputState( + inputs: .disabled, + message: "This group is read-only." + ) + + case .community: + return InputView.InputState( + inputs: .disabled, + message: "permissionsWriteCommunity".localized() + ) + } } /// Attachments shouldn't be allowed for message requests or if uploads are disabled @@ -556,34 +578,26 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold loadPageEvent: loadPageEvent ) - /// Peform any `libSession` changes - if fetchRequirements.needsAnyFetch { - do { - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) - } - catch { - Log.warn(.conversation, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") - } - } - /// Peform any database changes if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { try await dependencies[singleton: .storage].readAsync { db in /// Fetch the `authMethod` if needed + /// + /// **Note:** It's possible that we won't be able to fetch the `authMethod` (eg. if a group was destroyed or + /// the user was kicked from a group), in that case just fail silently (it's an expected behaviour - won't be able to + /// send requests anymore) if fetchRequirements.requireAuthMethodFetch { // TODO: [Database Relocation] Should be able to remove the database requirement now we have the CommunityManager + let maybeAuthMethod: AuthenticationMethod? = try? Authentication.with( + db, + threadId: threadInfo.id, + threadVariant: threadInfo.variant, + using: dependencies + ) + authMethod = EquatableAuthenticationMethod( - value: try Authentication.with( - db, - threadId: threadInfo.id, - threadVariant: threadInfo.variant, - using: dependencies - ) + value: (maybeAuthMethod ?? Authentication.invalid) ) } @@ -632,6 +646,20 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold Log.warn(.conversation, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.conversation, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + /// Update the typing indicator state if needed changes.forEach(.typingIndicator, as: TypingIndicatorEvent.self) { event in shouldShowTypingIndicator = (event.change == .started) @@ -1603,17 +1631,17 @@ public extension ConversationViewModel { threadIdsNeedingFetch: [threadId] ) - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) dataCache = try ConversationDataHelper.fetchFromDatabase( db, requirements: fetchRequirements, currentCache: dataCache, using: dependencies ) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) guard let thread: SessionThread = dataCache.thread(for: threadId) else { Log.error(.conversation, "Unable to fetch conversation info for thread: \(threadId).") diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index a695b8c4ef..d641e87688 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -311,20 +311,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi loadPageEvent: nil ) - /// Peform any `libSession` changes - if fetchRequirements.needsAnyFetch { - do { - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) - } - catch { - Log.warn(.threadSettingsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") - } - } - /// Peform any database changes if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { @@ -346,6 +332,20 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi Log.warn(.threadSettingsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.threadSettingsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + if let updatedValue: ThreadSettingsViewModelEvent = changes.latestGeneric(.updateScreen, as: ThreadSettingsViewModelEvent.self) { profileImageStatus = updatedValue.profileImageStatus } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 24fbdf8774..0340850010 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -302,20 +302,6 @@ public class HomeViewModel: NavigatableStateHolder { loadPageEvent: loadPageEvent ) - /// Peform any `libSession` changes - if fetchRequirements.needsAnyFetch { - do { - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) - } - catch { - Log.warn(.homeViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") - } - } - /// Peform any database changes if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { @@ -372,6 +358,20 @@ public class HomeViewModel: NavigatableStateHolder { Log.warn(.homeViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.homeViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + /// Then handle remaining non-database events changes.forEachEvent(.setting, as: Bool.self) { event, updatedValue in switch event.key { diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index ec22ab5810..8e4ed7459c 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -198,20 +198,6 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O loadPageEvent: loadPageEvent ) - /// Peform any `libSession` changes - if fetchRequirements.needsAnyFetch { - do { - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) - } - catch { - Log.warn(.messageRequestsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") - } - } - /// Peform any database changes if !dependencies[singleton: .storage].isSuspended, fetchRequirements.needsAnyFetch { do { @@ -235,6 +221,20 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O Log.warn(.messageRequestsViewModel, "Ignored \(changes.databaseEvents.count) database event(s) sent while storage was suspended.") } + /// Peform any `libSession` changes + if fetchRequirements.needsAnyFetch { + do { + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) + } + catch { + Log.warn(.messageRequestsViewModel, "Failed to handle \(changes.libSessionEvents.count) libSession event(s) due to error: \(error).") + } + } + /// Regenerate the `itemCache` now that the `dataCache` is updated itemCache = loadResult.info.currentIds.reduce(into: [:]) { result, id in guard let thread: SessionThread = dataCache.thread(for: id) else { return } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 8a07b9e7a8..bb6ba7e8ca 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -338,7 +338,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC bottomLabelStackView.isHidden = false displayNameLabel.themeAttributedText = cellViewModel.displayName.formatted(baseFont: displayNameLabel.font) displayNameLabel.isProBadgeHidden = !cellViewModel.shouldShowProBadge - snippetLabel.themeAttributedText = cellViewModel.targetInteraction?.messageSnippet? + snippetLabel.themeAttributedText = cellViewModel.messageSnippet? .formatted(baseFont: snippetLabel.font) .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } @@ -439,7 +439,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC }() typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() - snippetLabel.themeAttributedText = cellViewModel.lastInteraction?.messageSnippet? + snippetLabel.themeAttributedText = cellViewModel.messageSnippet? .formatted(baseFont: snippetLabel.font) .stylingNotificationPrefixesIfNeeded(fontSize: Values.verySmallFontSize) } diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index 7620aa326c..774a6a5271 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -93,9 +93,12 @@ public enum ConfigurationSyncJob: JobExecutor { AnyPublisher .lazy { () -> Network.PreparedRequest in - let authMethod: AuthenticationMethod = try Authentication.with( - swarmPublicKey: swarmPublicKey, - using: dependencies + let authMethod: AuthenticationMethod = try ( + additionalTransientData?.customAuthMethod ?? + Authentication.with( + swarmPublicKey: swarmPublicKey, + using: dependencies + ) ) return try Network.SnodeAPI.preparedSequence( @@ -337,24 +340,28 @@ extension ConfigurationSyncJob { public let afterSequenceRequests: [any ErasedPreparedRequest] public let requireAllBatchResponses: Bool public let requireAllRequestsSucceed: Bool + public let customAuthMethod: AuthenticationMethod? init?( beforeSequenceRequests: [any ErasedPreparedRequest], afterSequenceRequests: [any ErasedPreparedRequest], requireAllBatchResponses: Bool, - requireAllRequestsSucceed: Bool + requireAllRequestsSucceed: Bool, + customAuthMethod: AuthenticationMethod? ) { guard !beforeSequenceRequests.isEmpty || !afterSequenceRequests.isEmpty || requireAllBatchResponses || - requireAllRequestsSucceed + requireAllRequestsSucceed || + customAuthMethod != nil else { return nil } self.beforeSequenceRequests = beforeSequenceRequests self.afterSequenceRequests = afterSequenceRequests self.requireAllBatchResponses = requireAllBatchResponses self.requireAllRequestsSucceed = requireAllRequestsSucceed + self.customAuthMethod = customAuthMethod } } } @@ -414,6 +421,7 @@ public extension ConfigurationSyncJob { afterSequenceRequests: [any ErasedPreparedRequest] = [], requireAllBatchResponses: Bool = false, requireAllRequestsSucceed: Bool = false, + customAuthMethod: AuthenticationMethod? = nil, using dependencies: Dependencies ) -> AnyPublisher { return Deferred { @@ -428,7 +436,8 @@ public extension ConfigurationSyncJob { beforeSequenceRequests: beforeSequenceRequests, afterSequenceRequests: afterSequenceRequests, requireAllBatchResponses: requireAllBatchResponses, - requireAllRequestsSucceed: requireAllRequestsSucceed + requireAllRequestsSucceed: requireAllRequestsSucceed, + customAuthMethod: customAuthMethod ) ) else { return resolver(Result.failure(NetworkError.parsingFailed)) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 6726d4b77f..d89149040c 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -131,6 +131,10 @@ extension MessageSender { try await ConfigurationSyncJob.run( swarmPublicKey: preparedGroupData.groupSessionId.hexString, requireAllRequestsSucceed: true, + customAuthMethod: Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ), using: dependencies ).values.first { _ in true } } diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index c1db27f4ef..4fde3dc8f4 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -51,6 +51,7 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has public let isTyping: Bool public let userCount: Int? public let memberNames: String + public let messageSnippet: String? public let targetInteraction: InteractionInfo? public let lastInteraction: InteractionInfo? public let userSessionId: SessionId @@ -85,12 +86,16 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has ) { let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: thread.id) let isMessageRequest: Bool = ( - dataCache.group(for: thread.id)?.invited == true || ( + ( + thread.variant == .group && + dataCache.group(for: thread.id)?.invited != false + ) || ( + thread.variant == .contact && !currentUserSessionIds.contains(thread.id) && - dataCache.contact(for: thread.id)?.isApproved == false + dataCache.contact(for: thread.id)?.isApproved != true ) ) - let requiresApproval: Bool = (dataCache.contact(for: thread.id)?.didApproveMe == false) + let requiresApproval: Bool = (dataCache.contact(for: thread.id)?.didApproveMe != true) let sortedMemberIds: [String] = dataCache.groupMembers(for: thread.id) .map({ $0.profileId }) .filter({ !currentUserSessionIds.contains($0) }) @@ -116,18 +121,26 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has case .community: return nil } }() - let lastInteraction: InteractionInfo? = dataCache.interactionStats(for: thread.id).map { - dataCache.interaction(for: $0.latestInteractionId).map { - InteractionInfo( - interaction: $0, - searchText: searchText, - threadVariant: thread.variant, - userSessionId: dataCache.userSessionId, - dataCache: dataCache, - using: dependencies - ) - } + let lastInteractionContentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( + interaction: dataCache.interactionStats(for: thread.id).map { + dataCache.interaction(for: $0.latestInteractionId) + }, + threadId: thread.id, + threadVariant: thread.variant, + searchText: searchText, + dataCache: dataCache + ) + let targetInteractionContentBuilder: Interaction.ContentBuilder? = targetInteractionId.map { + Interaction.ContentBuilder( + interaction: dataCache.interaction(for: $0), + threadId: thread.id, + threadVariant: thread.variant, + searchText: searchText, + dataCache: dataCache + ) } + + let lastInteraction: InteractionInfo? = InteractionInfo(contentBuilder: lastInteractionContentBuilder) let groupInfo: GroupInfo? = dataCache.group(for: thread.id).map { GroupInfo( group: $0, @@ -295,21 +308,14 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has content: memberNameString ) }() + self.messageSnippet = (targetInteractionContentBuilder ?? lastInteractionContentBuilder) + .makeSnippet(dateNow: dependencies.dateNow) self.unreadCount = (dataCache.interactionStats(for: thread.id)?.unreadCount ?? 0) self.unreadMentionCount = (dataCache.interactionStats(for: thread.id)?.unreadMentionCount ?? 0) self.hasUnreadMessagesOfAnyKind = (dataCache.interactionStats(for: thread.id)?.hasUnreadMessagesOfAnyKind == true) - self.targetInteraction = targetInteractionId.map { id in - dataCache.interaction(for: id).map { - InteractionInfo( - interaction: $0, - searchText: searchText, - threadVariant: thread.variant, - userSessionId: dataCache.userSessionId, - dataCache: dataCache, - using: dependencies - ) - } + self.targetInteraction = targetInteractionContentBuilder.map { + InteractionInfo(contentBuilder: $0) } self.lastInteraction = lastInteraction self.userSessionId = dataCache.userSessionId @@ -560,6 +566,7 @@ public extension ConversationInfoViewModel { self.isTyping = false self.userCount = nil self.memberNames = "" + self.messageSnippet = "" self.targetInteraction = nil self.lastInteraction = nil self.userSessionId = .invalid @@ -682,24 +689,12 @@ public extension ConversationInfoViewModel { public let state: Interaction.State public let hasBeenReadByRecipient: Bool public let hasAttachments: Bool - public let messageSnippet: String? - public init?( - interaction: Interaction, - searchText: String?, - threadVariant: SessionThread.Variant, - userSessionId: SessionId, - dataCache: ConversationDataCache, - using dependencies: Dependencies - ) { - guard let interactionId: Int64 = interaction.id else { return nil } - - let contentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( - interaction: interaction, - threadVariant: threadVariant, - searchText: searchText, - dataCache: dataCache - ) + internal init?(contentBuilder: Interaction.ContentBuilder) { + guard + let interaction: Interaction = contentBuilder.interaction, + let interactionId: Int64 = interaction.id + else { return nil } self.id = interactionId self.threadId = interaction.threadId @@ -710,8 +705,7 @@ public extension ConversationInfoViewModel { self.timestampMs = interaction.timestampMs self.state = interaction.state self.hasBeenReadByRecipient = (interaction.recipientReadTimestampMs != nil) - self.hasAttachments = !dataCache.interactionAttachments(for: interactionId).isEmpty - self.messageSnippet = contentBuilder.makeSnippet(dateNow: dependencies.dateNow) + self.hasAttachments = contentBuilder.hasAttachments } } } diff --git a/SessionMessagingKit/Types/MessageViewModel.swift b/SessionMessagingKit/Types/MessageViewModel.swift index 7950120d6f..ecc13f425b 100644 --- a/SessionMessagingKit/Types/MessageViewModel.swift +++ b/SessionMessagingKit/Types/MessageViewModel.swift @@ -250,6 +250,7 @@ public extension MessageViewModel { }() let contentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( interaction: interaction, + threadId: threadInfo.id, threadVariant: threadInfo.variant, dataCache: dataCache ) @@ -350,6 +351,7 @@ public extension MessageViewModel { }() let quotedContentBuilder: Interaction.ContentBuilder = Interaction.ContentBuilder( interaction: quotedInteraction, + threadId: threadInfo.id, threadVariant: threadInfo.variant, dataCache: dataCache ) @@ -929,25 +931,28 @@ private extension MessageViewModel { internal extension Interaction { struct ContentBuilder { - private let interaction: Interaction + public let interaction: Interaction? private let searchText: String? private let dataCache: ConversationDataCache + private let threadId: String private let threadVariant: SessionThread.Variant private let currentUserSessionIds: Set public let attachments: [Attachment] + public let hasAttachments: Bool public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? - public var rawBody: String? { interaction.body } + public var rawBody: String? { interaction?.body } public let authorDisplayName: String public let authorDisplayNameNoSuffix: String public let threadContactDisplayName: String - public var containsOnlyEmoji: Bool { interaction.body?.containsOnlyEmoji == true } - public var glyphCount: Int { interaction.body?.glyphCount ?? 0 } + public var containsOnlyEmoji: Bool { interaction?.body?.containsOnlyEmoji == true } + public var glyphCount: Int { interaction?.body?.glyphCount ?? 0 } init( - interaction: Interaction, + interaction: Interaction?, + threadId: String, threadVariant: SessionThread.Variant, searchText: String? = nil, dataCache: ConversationDataCache @@ -956,37 +961,49 @@ internal extension Interaction { self.searchText = searchText self.dataCache = dataCache - let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: interaction.threadId) - let linkPreviewInfo = ContentBuilder.resolveBestLinkPreview( - for: interaction, - dataCache: dataCache - ) + let currentUserSessionIds: Set = dataCache.currentUserSessionIds(for: threadId) + let linkPreviewInfo = interaction.map { + ContentBuilder.resolveBestLinkPreview( + for: $0, + dataCache: dataCache + ) + } + self.threadId = threadId self.threadVariant = threadVariant self.currentUserSessionIds = currentUserSessionIds - self.attachments = (interaction.id.map { dataCache.attachments(for: $0) } ?? []) + self.attachments = (interaction?.id.map { dataCache.attachments(for: $0) } ?? []) + self.hasAttachments = (interaction?.id.map { dataCache.interactionAttachments(for: $0).isEmpty } == false) self.linkPreview = linkPreviewInfo?.preview self.linkPreviewAttachment = linkPreviewInfo?.attachment - if currentUserSessionIds.contains(interaction.authorId) { - self.authorDisplayName = "you".localized() - self.authorDisplayNameNoSuffix = "you".localized() + if let authorId: String = interaction?.authorId { + if currentUserSessionIds.contains(authorId) { + self.authorDisplayName = "you".localized() + self.authorDisplayNameNoSuffix = "you".localized() + } + else { + let profile: Profile = ( + dataCache.profile(for: authorId) ?? + Profile.defaultFor(authorId) + ) + + self.authorDisplayName = profile.displayName( + includeSessionIdSuffix: (threadVariant == .community) + ) + self.authorDisplayNameNoSuffix = profile.displayName(includeSessionIdSuffix: false) + } } else { - let profile: Profile = ( - dataCache.profile(for: interaction.authorId) ?? - Profile.defaultFor(interaction.authorId) - ) - - self.authorDisplayName = profile.displayName( - includeSessionIdSuffix: (threadVariant == .community) - ) - self.authorDisplayNameNoSuffix = profile.displayName(includeSessionIdSuffix: false) + self.authorDisplayName = "" + self.authorDisplayNameNoSuffix = "" } - self.threadContactDisplayName = dataCache.contactDisplayName(for: interaction.threadId) + self.threadContactDisplayName = dataCache.contactDisplayName(for: threadId) } func makeBubbleBody() -> String? { + guard let interaction else { return nil } + if interaction.variant.isInfoMessage { return makePreviewText() } @@ -1012,6 +1029,8 @@ internal extension Interaction { } func makeBodyForCopying() -> String? { + guard let interaction else { return nil } + if interaction.variant.isInfoMessage { return makePreviewText() } @@ -1020,6 +1039,8 @@ internal extension Interaction { } func makePreviewText() -> String? { + guard let interaction else { return nil } + return Interaction.previewText( variant: interaction.variant, body: interaction.body, @@ -1041,13 +1062,13 @@ internal extension Interaction { func makeSnippet(dateNow: Date) -> String? { var result: String = "" let isSearchResult: Bool = (searchText != nil) - let groupInfo: LibSession.GroupInfo? = dataCache.groupInfo(for: interaction.threadId) + let groupInfo: LibSession.GroupInfo? = dataCache.groupInfo(for: threadId) let groupKicked: Bool = (groupInfo?.wasKickedFromGroup == true) let groupDestroyed: Bool = (groupInfo?.wasGroupDestroyed == true) let groupThreadTypes: Set = [.legacyGroup, .group, .community] let groupSourceTypes: Set = [.conversationList, .searchResults] let shouldIncludeAuthorPrefix: Bool = ( - !interaction.variant.isInfoMessage && + interaction?.variant.isInfoMessage == false && groupSourceTypes.contains(dataCache.context.source) && groupThreadTypes.contains(threadVariant) ) @@ -1063,7 +1084,7 @@ internal extension Interaction { /// Add status icon prefixes if shouldHaveStatusIcon { - if let thread = dataCache.thread(for: interaction.threadId) { + if let thread = dataCache.thread(for: threadId) { let now: TimeInterval = dateNow.timeIntervalSince1970 let mutedUntil: TimeInterval = (thread.mutedUntilTimestamp ?? 0) @@ -1079,7 +1100,7 @@ internal extension Interaction { } /// If it's a group conversation then it might have a specia status - switch (groupInfo, groupDestroyed, groupKicked, interaction.variant) { + switch (groupInfo, groupDestroyed, groupKicked, interaction?.variant) { case (.some(let groupInfo), true, _, _): result.append( "groupDeletedMemberDescription" @@ -1109,7 +1130,7 @@ internal extension Interaction { in: previewText, currentUserSessionIds: currentUserSessionIds, displayNameRetriever: dataCache.displayNameRetriever( - for: interaction.threadId, + for: threadId, includeSessionIdSuffixWhenInMessageBody: (threadVariant == .community) ) ) diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 5aca17caa4..80d4e6c773 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -74,11 +74,6 @@ public class ThreadPickerViewModel { itemCache: [ConversationInfoViewModel.ID: ConversationInfoViewModel](), loadPageEvent: .initial ) - dataCache = try ConversationDataHelper.fetchFromLibSession( - requirements: fetchRequirements, - cache: dataCache, - using: dependencies - ) /// Fetch any required data from the cache var loadResult: PagedData.LoadResult = PagedData.LoadedInfo( @@ -97,6 +92,11 @@ public class ThreadPickerViewModel { loadPageEvent: .initial, using: dependencies ) + dataCache = try ConversationDataHelper.fetchFromLibSession( + requirements: fetchRequirements, + cache: dataCache, + using: dependencies + ) return (loadResult.info.currentIds, dataCache) } diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 7b8b99e607..2b7e52036f 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -139,12 +139,14 @@ public class Dependencies { } public func set(singleton: SingletonConfig, to instance: S) { - setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier) + let isNoop: Bool = (instance is NoopDependency) + setValue(instance, typedStorage: .singleton(instance, isNoop: isNoop), key: singleton.identifier) } public func set(cache: CacheConfig, to instance: M) { let value: ThreadSafeObject = ThreadSafeObject(cache.mutableInstance(instance)) - setValue(value, typedStorage: .cache(value), key: cache.identifier) + let isNoop: Bool = (instance is NoopDependency) + setValue(value, typedStorage: .cache(value, isNoop: isNoop), key: cache.identifier) } public func remove(cache: CacheConfig) { @@ -211,8 +213,9 @@ public extension Dependencies { typedValue?.value(as: Feature.self) ?? feature.createInstance(self) ) + let isNoop: Bool = (instance is NoopDependency) instance.setValue(to: updatedFeature, using: self) - setValue(instance, typedStorage: .feature(instance), key: feature.identifier) + setValue(instance, typedStorage: .feature(instance, isNoop: isNoop), key: feature.identifier) /// Notify observers notifyAsync(events: [ @@ -282,17 +285,16 @@ private extension Dependencies { var instances: [Key: Value] = [:] enum Value { - case singleton(Any) - case cache(ThreadSafeObject) - case userDefaults(UserDefaultsType) - case feature(any FeatureType) + case singleton(Any, isNoop: Bool) + case cache(ThreadSafeObject, isNoop: Bool) + case userDefaults(UserDefaultsType, isNoop: Bool) + case feature(any FeatureType, isNoop: Bool) var isNoop: Bool { switch self { - case .singleton(let value): return value is NoopDependency - case .userDefaults(let value): return value is NoopDependency - case .feature(let value): return value is NoopDependency - case .cache(let value): return value.performMap { $0 is NoopDependency } + case .singleton(_, let isNoop), .userDefaults(_, let isNoop), + .feature(_, let isNoop), .cache(_, let isNoop): + return isNoop } } @@ -307,10 +309,10 @@ private extension Dependencies { func value(as type: T.Type) -> T? { switch self { - case .singleton(let value): return value as? T - case .cache(let value): return value as? T - case .userDefaults(let value): return value as? T - case .feature(let value): return value as? T + case .singleton(let value, _): return value as? T + case .cache(let value, _): return value as? T + case .userDefaults(let value, _): return value as? T + case .feature(let value, _): return value as? T } } } @@ -429,32 +431,38 @@ private extension Dependencies.DependencyStorage { static func singleton(_ constructor: @escaping () -> T) -> Constructor { return Constructor(variant: .singleton) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.singleton(instance), instance) + return (.singleton(instance, isNoop: isNoop), instance) } } static func cache(_ constructor: @escaping () -> T) -> Constructor where T: ThreadSafeObject { return Constructor(variant: .cache) { + /// We need to peek at the wrapped value to check if it's a `NoopDependency` so use `performMap` to access + /// it safely let instance: T = constructor() + let isNoop: Bool = instance.performMap { $0 is NoopDependency } - return (.cache(instance), instance) + return (.cache(instance, isNoop: isNoop), instance) } } static func userDefaults(_ constructor: @escaping () -> T) -> Constructor where T == UserDefaultsType { return Constructor(variant: .userDefaults) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.userDefaults(instance), instance) + return (.userDefaults(instance, isNoop: isNoop), instance) } } static func feature(_ constructor: @escaping () -> T) -> Constructor where T: FeatureType { return Constructor(variant: .feature) { let instance: T = constructor() + let isNoop: Bool = (instance is NoopDependency) - return (.feature(instance), instance) + return (.feature(instance, isNoop: isNoop), instance) } } } From 60af69decabb3c12920eb4ac14183832625651d4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Dec 2025 17:25:43 +1100 Subject: [PATCH 56/66] Fixed a few more issues from regression tests --- .../Message Cells/VisibleMessageCell.swift | 17 +++++++----- .../Jobs/DisappearingMessagesJob.swift | 7 +++++ .../Jobs/GetExpirationJob.swift | 27 +++++++++++++++++++ .../Types/ConversationDataHelper.swift | 4 +-- .../Types/ConversationInfoViewModel.swift | 25 +++++++++-------- .../ObservableKey+SessionMessagingKit.swift | 1 + 6 files changed, 59 insertions(+), 22 deletions(-) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index f1d9b169af..37ae301d4e 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -424,22 +424,25 @@ final class VisibleMessageCell: MessageCell { hasBeenReadByRecipient: cellViewModel.hasBeenReadByRecipient, hasAttachments: !cellViewModel.attachments.isEmpty ) - messageStatusLabel.text = statusText - messageStatusLabel.themeTextColor = tintColor - messageStatusImageView.image = image - messageStatusLabel.accessibilityIdentifier = "Message sent status: \(statusText ?? "invalid")" - messageStatusImageView.themeTintColor = tintColor - messageStatusStackView.isHidden = ( + let expectedMessageStatusHiddenState: Bool = ( (cellViewModel.expiresInSeconds ?? 0) == 0 && ( !cellViewModel.variant.isOutgoing || cellViewModel.variant.isDeletedMessage || cellViewModel.variant == .infoCall || + cellViewModel.state == .localOnly || ( cellViewModel.state == .sent && !cellViewModel.isLastOutgoing ) ) ) + messageStatusLabel.text = statusText + messageStatusLabel.themeTextColor = tintColor + messageStatusLabel.accessibilityIdentifier = "Message sent status: \(statusText ?? "invalid")" + messageStatusLabel.isHidden = (statusText ?? "").isEmpty + messageStatusImageView.image = image + messageStatusImageView.themeTintColor = tintColor + messageStatusStackView.isHidden = expectedMessageStatusHiddenState // Timer if @@ -459,7 +462,7 @@ final class VisibleMessageCell: MessageCell { } else { timerView.isHidden = true - messageStatusImageView.isHidden = false + messageStatusImageView.isHidden = expectedMessageStatusHiddenState } // Hide the underBubbleStackView if all of it's content is hidden diff --git a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift index e6689d73b0..8a21eae62d 100644 --- a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift @@ -206,6 +206,13 @@ public extension DisappearingMessagesJob { // If there were no changes then none of the provided `interactionIds` are expiring messages guard (changeCount ?? 0) > 0 else { return nil } + interactionExpirationInfosByExpiresInSeconds.flatMap { _, value in value }.forEach { info in + db.addMessageEvent( + id: info.id, + threadId: threadId, + type: .updated(.expirationTimerStarted(info.expiresInSeconds, startedAtMs))) + } + interactionExpirationInfosByExpiresInSeconds.forEach { expiresInSeconds, expirationInfos in let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000) dependencies[singleton: .jobRunner].add( diff --git a/SessionMessagingKit/Jobs/GetExpirationJob.swift b/SessionMessagingKit/Jobs/GetExpirationJob.swift index 567db05567..26c17b1f5b 100644 --- a/SessionMessagingKit/Jobs/GetExpirationJob.swift +++ b/SessionMessagingKit/Jobs/GetExpirationJob.swift @@ -12,6 +12,13 @@ public enum GetExpirationJob: JobExecutor { public static var requiresInteractionId: Bool = false private static let minRunFrequency: TimeInterval = 5 + private struct ExpirationInteractionInfo: Codable, Hashable, FetchableRecord { + let id: Int64 + let threadId: String + let expiresInSeconds: TimeInterval + let expiresStartedAtMs: Double + } + public static func run( _ job: Job, scheduler: S, @@ -68,6 +75,7 @@ public enum GetExpirationJob: JobExecutor { var hashesWithNoExiprationInfo: Set = Set(expirationInfo.keys) .subtracting(serverSpecifiedExpirationStartTimesMs.keys) + dependencies[singleton: .storage].write { db in try serverSpecifiedExpirationStartTimesMs.forEach { hash, expiresStartedAtMs in try Interaction @@ -103,6 +111,25 @@ public enum GetExpirationJob: JobExecutor { Interaction.Columns.expiresStartedAtMs.set(to: details.startedAtTimestampMs) ) + /// Send events that the expiration started + let allHashes: Set = hashesWithNoExiprationInfo + .inserting(contentsOf: Set(serverSpecifiedExpirationStartTimesMs.keys)) + let interactionInfo: [ExpirationInteractionInfo] = ((try? Interaction + .select(.id, .threadId, .expiresInSeconds, .expiresStartedAtMs) + .filter(allHashes.contains(Interaction.Columns.serverHash)) + .filter(Interaction.Columns.expiresInSeconds != nil) + .filter(Interaction.Columns.expiresStartedAtMs != nil) + .asRequest(of: ExpirationInteractionInfo.self) + .fetchAll(db)) ?? []) + + interactionInfo.forEach { info in + db.addMessageEvent( + id: info.id, + threadId: info.threadId, + type: .updated(.expirationTimerStarted(info.expiresInSeconds, info.expiresStartedAtMs)) + ) + } + dependencies[singleton: .jobRunner].upsert( db, job: DisappearingMessagesJob.updateNextRunIfNeeded(db, using: dependencies), diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 3fbce24ec0..87388a28dd 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -929,8 +929,8 @@ private extension ConversationDataHelper { } switch cache.context.source { - case .messageList, .conversationSettings, .searchResults: break - case .conversationList: + case .conversationSettings, .searchResults: break + case .conversationList, .messageList: /// Any message event means we need to refetch interaction stats and latest message requirements.threadIdsNeedingInteractionStats.insert(messageEvent.threadId) } diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index 4fde3dc8f4..679c9b1599 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -395,19 +395,18 @@ extension ConversationInfoViewModel: ObservableKeyProvider { result.insert(.profile(additionalProfile.id)) } - if self.contactInfo != nil { - result.insert(.contact(id)) - } - - if self.groupInfo != nil { - result.insert(.groupInfo(groupId: id)) - result.insert(.groupMemberCreated(threadId: id)) - result.insert(.anyGroupMemberDeleted(threadId: id)) - } - - if self.communityInfo != nil { - result.insert(.communityUpdated(id)) - result.insert(.anyContactUnblinded) /// To update profile info and blinded mapping + switch variant { + case .contact: result.insert(.contact(id)) + case .group: + result.insert(.groupInfo(groupId: id)) + result.insert(.groupMemberCreated(threadId: id)) + result.insert(.anyGroupMemberDeleted(threadId: id)) + + case .community: + result.insert(.communityUpdated(id)) + result.insert(.anyContactUnblinded) /// To update profile info and blinded mapping + + case .legacyGroup: break } return result diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 8895b39d93..c4f9bbbdb5 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -318,6 +318,7 @@ public struct MessageEvent: Hashable { case state(Interaction.State) case recipientReadTimestampMs(Int64) case markedAsDeleted + case expirationTimerStarted(TimeInterval, Double) } } From 6a63a0730d98b68872034d15888d2e28ac18aa51 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sat, 20 Dec 2025 06:44:59 +1100 Subject: [PATCH 57/66] Fixed a few more regression test issues --- .../ConversationVC+Interaction.swift | 37 ++- Session/Conversations/ConversationVC.swift | 14 +- .../Settings/ThreadSettingsViewModel.swift | 37 +-- Session/Onboarding/Onboarding.swift | 9 +- .../UIContextualAction+Utilities.swift | 14 +- .../Database/Models/SessionThread.swift | 228 +++++++----------- .../Sending & Receiving/MessageReceiver.swift | 11 +- .../Types/ConversationDataHelper.swift | 7 +- .../Types/ConversationInfoViewModel.swift | 2 +- .../ObservableKey+SessionMessagingKit.swift | 4 +- SessionShareExtension/ThreadPickerVC.swift | 11 +- 11 files changed, 168 insertions(+), 206 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 761a629aab..3a36f7f119 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -852,16 +852,16 @@ extension ConversationVC: do { try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if !state.threadInfo.shouldBeVisible { - try SessionThread.updateVisibility( - db, - threadId: state.threadId, - threadVariant: state.threadVariant, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], - using: dependencies - ) - } + try SessionThread.upsert( + db, + id: state.threadId, + variant: state.threadVariant, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), + using: dependencies + ) // Insert the interaction and associated it with the optimistically inserted message so // we can remove it once the database triggers a UI update @@ -1996,15 +1996,14 @@ extension ConversationVC: let destination: Message.Destination = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) - if threadShouldBeVisible == false { - try SessionThread.updateVisibility( - db, - threadId: cellViewModel.threadId, - threadVariant: cellViewModel.threadVariant, - isVisible: true, - using: dependencies - ) - } + try SessionThread.update( + db, + id: cellViewModel.threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true) + ), + using: dependencies + ) // Get the pending reaction if remove { diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 4348f0172b..98923d88ee 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -654,8 +654,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa /// If the user just created this thread but didn't send a message or the conversation is marked as hidden then we want to delete the /// "shadow" thread since it's not actually in use (this is to prevent it from taking up database space or unintentionally getting synced /// via `libSession` in the future) - let threadId: String = viewModel.state.threadId - if ( self.navigationController == nil || @@ -664,10 +662,14 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa !viewModel.state.threadInfo.isNoteToSelf && viewModel.state.threadInfo.isDraft { - viewModel.dependencies[singleton: .storage].writeAsync { db in - _ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave` - .filter(id: threadId) - .deleteAll(db) + viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in + try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, + threadId: state.threadInfo.id, + threadVariant: state.threadInfo.variant, + using: dependencies + ) } } } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index d641e87688..5b168f0e45 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1048,11 +1048,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi onTap: { [dependencies = viewModel.dependencies] in dependencies[singleton: .storage].writeAsync { db in if isThreadHidden { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: state.threadInfo.id, - threadVariant: state.threadInfo.variant, - isVisible: true, + id: state.threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true) + ), using: dependencies ) } else { @@ -2219,12 +2220,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } /// We have the space to pin the conversation, so do so - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadInfo.id, - threadVariant: threadInfo.variant, - isVisible: true, - customPriority: (threadInfo.pinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ? + 1 : + LibSession.visiblePriority + ) + ), using: dependencies ) @@ -2259,12 +2264,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // If we are unpinning then no need to check the current count, just unpin immediately try? await dependencies[singleton: .storage].writeAsync { [dependencies] db in - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadInfo.id, - threadVariant: threadInfo.variant, - isVisible: true, - customPriority: (threadInfo.pinnedPriority <= LibSession.visiblePriority ? 1 : LibSession.visiblePriority), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(threadInfo.pinnedPriority <= LibSession.visiblePriority ? + 1 : + LibSession.visiblePriority + ) + ), using: dependencies ) } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 3a96cb08cb..1d2a5b727a 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -435,11 +435,12 @@ extension Onboarding { /// won't actually get synced correctly and could result in linking a second device and having the 'Note to Self' conversation incorrectly /// being visible if initialFlow == .register { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: userSessionId.hexString, - threadVariant: .contact, - isVisible: false, + id: userSessionId.hexString, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), using: dependencies ) } diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 48783d686c..a81abac0f8 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -270,12 +270,16 @@ public extension UIContextualAction { // Delay the change to give the cell "unswipe" animation some time to complete DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { dependencies[singleton: .storage].writeAsync { db in - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadInfo.id, - threadVariant: threadInfo.variant, - isVisible: true, - customPriority: (isCurrentlyPinned ? LibSession.visiblePriority : 1), + id: threadInfo.id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + pinnedPriority: .setTo(isCurrentlyPinned ? + LibSession.visiblePriority : + 1 + ) + ), using: dependencies ) } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index ad0bc2df82..25a50beba5 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -328,14 +328,35 @@ public extension SessionThread { ).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 = result.creationDateTimestamp - var finalShouldBeVisible: Bool = result.shouldBeVisible - var finalPinnedPriority: Int32? = result.pinnedPriority - var finalIsDraft: Bool? = result.isDraft - var finalMutedUntilTimestamp: TimeInterval? = result.mutedUntilTimestamp - var finalOnlyNotifyForMentions: Bool = result.onlyNotifyForMentions + 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( @@ -392,12 +413,12 @@ public extension SessionThread { } /// And update any explicit `setTo` cases - if case .setTo(let value) = values.creationDateTimestamp, value != result.creationDateTimestamp { + 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 != result.shouldBeVisible { + if case .setTo(let value) = values.shouldBeVisible, value != shouldBeVisible { requiredChanges.append(SessionThread.Columns.shouldBeVisible.set(to: value)) finalShouldBeVisible = value db.addConversationEvent( @@ -419,7 +440,7 @@ public extension SessionThread { } } - if case .setTo(let value) = values.pinnedPriority, value != result.pinnedPriority { + if case .setTo(let value) = values.pinnedPriority, value != pinnedPriority { requiredChanges.append(SessionThread.Columns.pinnedPriority.set(to: value)) finalPinnedPriority = value db.addConversationEvent( @@ -429,12 +450,17 @@ public extension SessionThread { ) } - if case .setTo(let value) = values.isDraft, value != result.isDraft { + 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 != result.mutedUntilTimestamp { + if case .setTo(let value) = values.mutedUntilTimestamp, value != mutedUntilTimestamp { requiredChanges.append(SessionThread.Columns.mutedUntilTimestamp.set(to: value)) finalMutedUntilTimestamp = value db.addConversationEvent( @@ -444,7 +470,7 @@ public extension SessionThread { ) } - if case .setTo(let value) = values.onlyNotifyForMentions, value != result.onlyNotifyForMentions { + if case .setTo(let value) = values.onlyNotifyForMentions, value != onlyNotifyForMentions { requiredChanges.append(SessionThread.Columns.onlyNotifyForMentions.set(to: value)) finalOnlyNotifyForMentions = value db.addConversationEvent( @@ -455,7 +481,7 @@ public extension SessionThread { } /// If no changes were needed we can just return the existing/default thread - guard !requiredChanges.isEmpty else { return result } + guard !requiredChanges.isEmpty else { return self } /// Otherwise save the changes try SessionThread @@ -466,24 +492,15 @@ public extension SessionThread { 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( - toThrowing: try SessionThread( - id: id, - variant: variant, - creationDateTimestamp: finalCreationDateTimestamp, - shouldBeVisible: finalShouldBeVisible, - mutedUntilTimestamp: finalMutedUntilTimestamp, - onlyNotifyForMentions: finalOnlyNotifyForMentions, - pinnedPriority: finalPinnedPriority, - isDraft: finalIsDraft - ).upserted(db) - ) + /// 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( @@ -606,13 +623,16 @@ public extension SessionThread { switch type { case .hideContactConversation: - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - threadVariant: threadVariant, - isVisible: false, - using: dependencies - ) + 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 @@ -622,13 +642,16 @@ public extension SessionThread { ) // Hide the threads - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - threadVariant: threadVariant, - isVisible: false, - using: dependencies - ) + 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) @@ -671,13 +694,16 @@ public extension SessionThread { .filter(Interaction.Columns.threadId == userSessionId.hexString) ) - try SessionThread.updateVisibility( - db, - threadIds: threadIds, - threadVariant: threadVariant, - isVisible: false, - using: dependencies - ) + try threadIds.forEach { id in + try SessionThread.update( + db, + id: id, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(false) + ), + using: dependencies + ) + } } // Remove desired deduplication records @@ -758,117 +784,29 @@ public extension SessionThread { public extension SessionThread { func with( + creationDateTimestamp: Update = .useExisting, shouldBeVisible: Update = .useExisting, messageDraft: Update = .useExisting, mutedUntilTimestamp: Update = .useExisting, onlyNotifyForMentions: Update = .useExisting, markedAsUnread: Update = .useExisting, - pinnedPriority: Update = .useExisting + pinnedPriority: Update = .useExisting, + isDraft: Update = .useExisting ) -> SessionThread { return SessionThread( id: id, variant: variant, - creationDateTimestamp: creationDateTimestamp, + 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 + isDraft: isDraft.or(self.isDraft) ) } - static func updateVisibility( - _ db: ObservingDatabase, - threadId: String, - threadVariant: SessionThread.Variant, - isVisible: Bool, - customPriority: Int32? = nil, - additionalChanges: [ConfigColumnAssignment] = [], - using dependencies: Dependencies - ) throws { - try updateVisibility( - db, - threadIds: [threadId], - threadVariant: threadVariant, - isVisible: isVisible, - customPriority: customPriority, - additionalChanges: additionalChanges, - using: dependencies - ) - } - - static func updateVisibility( - _ db: ObservingDatabase, - threadIds: [String], - threadVariant: SessionThread.Variant, - isVisible: Bool, - customPriority: Int32? = nil, - additionalChanges: [ConfigColumnAssignment] = [], - using dependencies: Dependencies - ) throws { - struct ThreadInfo: Decodable, FetchableRecord { - var id: String - var shouldBeVisible: Bool - var pinnedPriority: Int32 - } - - let targetPriority: Int32 - - switch (customPriority, isVisible) { - case (.some(let priority), _): targetPriority = priority - case (.none, true): targetPriority = LibSession.visiblePriority - case (.none, false): targetPriority = LibSession.hiddenPriority - } - - let currentInfo: [String: ThreadInfo] = try SessionThread - .select(.id, .shouldBeVisible, .pinnedPriority) - .filter(ids: threadIds) - .asRequest(of: ThreadInfo.self) - .fetchAll(db) - .reduce(into: [:]) { result, next in - result[next.id] = next - } - - _ = try SessionThread - .filter(ids: threadIds) - .updateAllAndConfig( - db, - [ - SessionThread.Columns.pinnedPriority.set(to: targetPriority), - SessionThread.Columns.shouldBeVisible.set(to: isVisible) - ].appending(contentsOf: additionalChanges), - using: dependencies - ) - - /// Emit events for any changes - threadIds.forEach { id in - if currentInfo[id]?.shouldBeVisible != isVisible { - db.addConversationEvent( - id: id, - variant: threadVariant, - type: .updated(.shouldBeVisible(isVisible)) - ) - - /// Toggling visibility is the same as "creating"/"deleting" a conversation - db.addConversationEvent( - id: id, - variant: threadVariant, - type: (isVisible ? .created : .deleted) - ) - } - - if currentInfo[id]?.pinnedPriority != targetPriority { - db.addConversationEvent( - id: id, - variant: threadVariant, - type: .updated(.pinnedPriority(targetPriority)) - ) - } - } - } - static func unreadMessageRequestsQuery(messageRequestThreadIds: Set) -> SQLRequest { let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 5d81794e5c..96cfdc0508 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -412,12 +412,13 @@ public enum MessageReceiver { guard !isCurrentlyVisible else { return } - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadId, - threadVariant: threadVariant, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + id: threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), using: dependencies ) } diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 87388a28dd..8906a2d83a 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -305,7 +305,12 @@ public extension ConversationDataHelper { updatedCache.insert(thread.with(markedAsUnread: .set(to: value))) - case (_, .draft(let value)): + case (_, .isDraft(let value)): + guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } + + updatedCache.insert(thread.with(isDraft: .set(to: value))) + + case (_, .messageDraft(let value)): guard let thread: SessionThread = updatedCache.thread(for: event.id) else { return } updatedCache.insert(thread.with(messageDraft: .set(to: value))) diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index 679c9b1599..6b4ffa7e87 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -508,7 +508,7 @@ public extension ConversationInfoViewModel { db.addConversationEvent( id: id, variant: variant, - type: .updated(.draft(draft)) + type: .updated(.messageDraft(draft)) ) } } diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index c4f9bbbdb5..997645a12b 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -284,7 +284,9 @@ public struct ConversationEvent: Hashable { case mutedUntilTimestamp(TimeInterval?) case onlyNotifyForMentions(Bool) case markedAsUnread(Bool) - case draft(String?) + case isDraft(Bool) + + case messageDraft(String?) case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) case unreadCount } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index ba6f37e120..f4f4399d47 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -347,12 +347,13 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView /// Update the thread to be visible (if it isn't already) if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { - try SessionThread.updateVisibility( + try SessionThread.update( db, - threadId: threadId, - threadVariant: thread.variant, - isVisible: true, - additionalChanges: [SessionThread.Columns.isDraft.set(to: false)], + id: threadId, + values: SessionThread.TargetValues( + shouldBeVisible: .setTo(true), + isDraft: .setTo(false) + ), using: dependencies ) } From 62c841c74721a989554b935ba93133e9033c55df Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sat, 20 Dec 2025 07:16:28 +1100 Subject: [PATCH 58/66] Fixed another regression test issue --- SessionMessagingKit/Types/ConversationInfoViewModel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index 6b4ffa7e87..5276a03b57 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -95,7 +95,10 @@ public struct ConversationInfoViewModel: PagableRecord, Sendable, Equatable, Has dataCache.contact(for: thread.id)?.isApproved != true ) ) - let requiresApproval: Bool = (dataCache.contact(for: thread.id)?.didApproveMe != true) + let requiresApproval: Bool = ( + thread.variant == .contact && + dataCache.contact(for: thread.id)?.didApproveMe != true + ) let sortedMemberIds: [String] = dataCache.groupMembers(for: thread.id) .map({ $0.profileId }) .filter({ !currentUserSessionIds.contains($0) }) From 6e675fb0fe75ee340478f2fbf46999fd8d36d92b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 23 Dec 2025 13:16:03 +1100 Subject: [PATCH 59/66] Fixed a few more regression issues --- Session/Conversations/ConversationViewModel.swift | 15 ++++++++------- .../Message Cells/InfoMessageCell.swift | 12 ++++++++++-- .../Database/Models/SessionThread.swift | 4 ++-- .../Config Handling/LibSession+GroupInfo.swift | 9 ++++++++- .../Message Handling/MessageReceiver+Groups.swift | 7 +++++++ .../Sending & Receiving/MessageSender.swift | 4 ---- .../Types/ConversationDataHelper.swift | 8 +++++++- .../ObservableKey+SessionMessagingKit.swift | 3 +++ .../Components/SwiftUI/QuoteView_SwiftUI.swift | 12 +++++++++--- 9 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 764e07d208..69254681f6 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1204,7 +1204,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// If we already have pending info to mark as read then no need to trigger another update if let pendingValue: Interaction.TimestampInfo = pendingMarkAsReadInfo { - /// If the target info is "newer" than the pending info then we sould update the pending info so the "newer" value ends + /// If the target info is "newer" than the pending info then we should update the pending info so the "newer" value ends /// up getting marked as read if targetInfo.id > pendingValue.id || targetInfo.timestampMs > pendingValue.timestampMs { pendingMarkAsReadInfo = targetInfo @@ -1226,10 +1226,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold /// Get the latest values let (threadInfo, pendingInfo): (ConversationInfoViewModel, Interaction.TimestampInfo?) = await MainActor.run { - ( + let result: (ConversationInfoViewModel, Interaction.TimestampInfo?) = ( state.threadInfo, pendingMarkAsReadInfo ) + + /// Immediately clear the pending info so we can mark something else as read while waiting in this message to be marked + /// as read + pendingMarkAsReadInfo = nil + + return result } guard let info: Interaction.TimestampInfo = pendingInfo else { return } @@ -1238,11 +1244,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold target: .threadAndInteractions(interactionsBeforeInclusive: info.id), using: dependencies ) - - /// Clear the pending info so we can mark something else as read - await MainActor.run { - pendingMarkAsReadInfo = nil - } } @MainActor public func trustContact() { diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index b0b9a523aa..73b065a2e2 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -7,6 +7,7 @@ import SessionUtilitiesKit final class InfoMessageCell: MessageCell { private static let iconSize: CGFloat = 12 + private static let font: UIFont = .systemFont(ofSize: Values.verySmallFontSize) public static let inset = Values.mediumSpacing private var isHandlingLongPress: Bool = false @@ -33,7 +34,7 @@ final class InfoMessageCell: MessageCell { private lazy var label: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.font = InfoMessageCell.font result.themeTextColor = .textSecondary result.textAlignment = .center result.lineBreakMode = .byWordWrapping @@ -44,7 +45,7 @@ final class InfoMessageCell: MessageCell { private lazy var actionLabel: UILabel = { let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.font = InfoMessageCell.font result.themeTextColor = .primary result.textAlignment = .center result.numberOfLines = 1 @@ -80,6 +81,13 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating + override func prepareForReuse() { + super.prepareForReuse() + + label.font = InfoMessageCell.font + actionLabel.font = InfoMessageCell.font + } + override func update( with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?, diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 25a50beba5..41d13de822 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -840,8 +840,8 @@ public extension SessionThread { profile: Profile? ) -> String { switch variant { - case .legacyGroup, .group: return (groupName ?? "groupUnknown".localized()) - case .community: return (communityName ?? "communityUnknown".localized()) + 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() } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index dabd016062..aa2dbbb90d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -58,7 +58,7 @@ internal extension LibSessionCacheType { throw LibSessionError.invalidConfigObject(wanted: .groupInfo, got: config) } - // If the group is destroyed then mark the group as kicked in the USER_GROUPS config and remove + // If the group is destroyed then mark the group as destroyed in the USER_GROUPS config and remove // the group data (want to keep the group itself around because the UX of conversations randomly // disappearing isn't great) - no other changes matter and this can't be reversed guard !groups_info_is_destroyed(conf) else { @@ -73,6 +73,13 @@ internal extension LibSessionCacheType { ], using: dependencies ) + + /// Notify of being marked as destroyed + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.markedAsDestroyed) + ) return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 40fbdeee4e..342b29d26a 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -840,6 +840,13 @@ extension MessageReceiver { groupSessionIds: [groupSessionId.hexString], using: dependencies ) + + /// Notify of being marked as kicked + db.addConversationEvent( + id: groupSessionId.hexString, + variant: .group, + type: .updated(.markedAsKicked) + ) } /// Delete the group data (if the group is a message request then delete it entirely, otherwise we want to keep a shell of group around because diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index b86450ce71..c750e84c54 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -187,7 +187,6 @@ public final class MessageSender { destination: destination, message: message, attachments: attachments, - authMethod: authMethod, using: dependencies ), ttl: Message.getSpecifiedTTL(message: message, destination: destination, using: dependencies), @@ -274,7 +273,6 @@ public final class MessageSender { destination: destination, message: message, attachments: attachments, - authMethod: authMethod, using: dependencies ) @@ -333,7 +331,6 @@ public final class MessageSender { destination: destination, message: message, attachments: nil, - authMethod: authMethod, using: dependencies ) @@ -363,7 +360,6 @@ public final class MessageSender { destination: Message.Destination, message: Message, attachments: [(attachment: Attachment, fileId: String)]?, - authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Data { /// Check the message itself is valid diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 8906a2d83a..eb70ab8045 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -323,6 +323,9 @@ public extension ConversationDataHelper { /// These need to be handled via a database query case (_, .unreadCount), (_, .none): return + /// These events are handled via a libSession query + case (_, .markedAsKicked), (_, .markedAsDestroyed): return + /// These events can be ignored as they will be handled via profile changes case (.contact, .displayName), (.contact, .displayPictureUrl): return @@ -533,7 +536,10 @@ public extension ConversationDataHelper { updatedRequirements.threadIdsNeedingInteractionStats.insert(contentsOf: Set(newIds)) } - case .messageList: + case .messageList(let threadId): + /// Always re-fetch the interaction stats + updatedRequirements.threadIdsNeedingInteractionStats.insert(threadId) + if let newIds: [Int64] = updatedLoadResult.newIds as? [Int64], !newIds.isEmpty { updatedRequirements.interactionIdsNeedingFetch.insert(contentsOf: Set(newIds)) } diff --git a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift index 997645a12b..7f3c49cbc7 100644 --- a/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/ObservableKey+SessionMessagingKit.swift @@ -289,6 +289,9 @@ public struct ConversationEvent: Hashable { case messageDraft(String?) case disappearingMessageConfiguration(DisappearingMessagesConfiguration?) case unreadCount + + case markedAsDestroyed + case markedAsKicked } } diff --git a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift index 9a791d88a9..fc2ebda6c0 100644 --- a/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/QuoteView_SwiftUI.swift @@ -176,13 +176,19 @@ public struct QuoteViewModel: Sendable, Equatable, Hashable { body: previewBody, attachmentInfo: nil ) - - /// This is an preview version so none of these values matter self.mode = .regular self.direction = .incoming self.targetThemeColor = .messageBubble_incomingText self.showProBadge = false - self.attributedText = ThemedAttributedString(string: previewBody) + self.attributedText = previewBody.formatted( + baseFont: .systemFont(ofSize: Values.smallFontSize), + attributes: [.themeForegroundColor: targetThemeColor], + mentionColor: MentionUtilities.mentionColor( + textColor: targetThemeColor, + location: .incomingQuote + ), + currentUserMentionImage: nil + ) } // MARK: - Conformance From 0b7d8b3b8077ccbf8b478f0b8cce836a6d0611e4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 23 Dec 2025 15:31:06 +1100 Subject: [PATCH 60/66] Fixed broken read receipts --- SessionMessagingKit/Jobs/SendReadReceiptsJob.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift index b31db68374..5fcc7c708f 100644 --- a/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/SendReadReceiptsJob.swift @@ -34,7 +34,7 @@ public enum SendReadReceiptsJob: JobExecutor { } AnyPublisher - .lazy { () -> AnyPublisher<(ResponseInfoType, Message), Error> in + .lazy { () -> Network.PreparedRequest in let authMethod: AuthenticationMethod = try Authentication.with( swarmPublicKey: threadId, using: dependencies @@ -51,8 +51,9 @@ public enum SendReadReceiptsJob: JobExecutor { authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies - ).send(using: dependencies) + ) } + .flatMap { $0.send(using: dependencies) } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) .sinkUntilComplete( From 4729f4cc039d3d20f4c836c42d8324a55b0a931e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 1 Jan 2026 15:36:53 +1100 Subject: [PATCH 61/66] Fixed broken community invitations and group description updates --- SessionMessagingKit/Database/Models/Interaction.swift | 2 +- .../LibSession/Config Handling/LibSession+GroupInfo.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 3c000f024d..4aad505811 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -993,7 +993,7 @@ public extension Interaction { FROM \(linkPreview) WHERE ( \(linkPreview[.url]) = \(url) AND - (\(linkPreview[.timestamp]) BETWEEN (\(minTimestamp) AND \(maxTimestamp)) AND + \(linkPreview[.timestamp]) BETWEEN (\(minTimestamp) AND \(maxTimestamp)) AND \(linkPreview[.variant]) IN \(variants) ) """ diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index aa2dbbb90d..49a33278aa 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -146,7 +146,7 @@ internal extension LibSessionCacheType { ) } - if existingGroup?.groupDescription == groupDesc { + if existingGroup?.groupDescription != groupDesc { db.addConversationEvent( id: groupSessionId.hexString, variant: .group, From 4b56fbd12ae8409411ae94d74f58d2f9c31bd76a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 2 Jan 2026 16:18:55 +1100 Subject: [PATCH 62/66] Fixed an issue with syncing deleted/kicked groups to linked devices --- .../Types/ConversationDataHelper.swift | 19 +++++++++++++++++++ .../Types/ConversationInfoViewModel.swift | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index eb70ab8045..83dc24ad83 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -158,6 +158,21 @@ public extension ConversationDataHelper { } } + /// Handle conversation update events which may require lib session fetching (eg. group changes) + changes.libSessionEvents.forEach { event in + switch (event.key.generic, event.value) { + case (.conversationUpdated, is ConversationEvent): + handleConversationEvent( + event, + cache: currentCache, + itemCache: itemCache, + requirements: &requirements + ) + + default: break + } + } + /// Handle any events which require a change to the message request count requirements.requiresMessageRequestCountUpdate = changes.databaseEvents.contains { event in switch event.key { @@ -848,6 +863,7 @@ public extension ConversationDataHelper { var updatedCache: ConversationDataCache = cache let groupInfoIdsNeedingFetch: Set = Set(cache.groups.keys) .filter { cache.groupInfo(for: $0) == nil } + .inserting(contentsOf: requirements.groupIdsNeedingFetch) if !groupInfoIdsNeedingFetch.isEmpty { let groupInfo: [LibSession.GroupInfo?] = dependencies.mutate(cache: .libSession) { cache in @@ -907,6 +923,9 @@ private extension ConversationDataHelper { requirements.interactionIdsNeedingFetch.insert(messageViewModel.id) } + + case (_, .markedAsKicked, _), (_, .markedAsDestroyed, _): + requirements.groupIdsNeedingFetch.insert(conversationEvent.id) default: break } diff --git a/SessionMessagingKit/Types/ConversationInfoViewModel.swift b/SessionMessagingKit/Types/ConversationInfoViewModel.swift index 5276a03b57..cc7e68da32 100644 --- a/SessionMessagingKit/Types/ConversationInfoViewModel.swift +++ b/SessionMessagingKit/Types/ConversationInfoViewModel.swift @@ -767,7 +767,7 @@ private extension ObservedEvent { case (_, .groupInfo): return .libSessionQuery case (_, .typingIndicator): return .directCacheUpdate - case (_, .conversationUpdated): return .directCacheUpdate + case (_, .conversationUpdated): return [.directCacheUpdate, .libSessionQuery] case (_, .contact): return .directCacheUpdate case (_, .communityUpdated): return .directCacheUpdate From e7fdf3c47b9c685e54864d7ad946e7ab948a1f39 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 2 Jan 2026 16:19:28 +1100 Subject: [PATCH 63/66] Fixed an issue where accepted message requests may not update the UI state --- .../Types/ConversationDataHelper.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 83dc24ad83..292cd9f4ef 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -558,6 +558,34 @@ public extension ConversationDataHelper { if let newIds: [Int64] = updatedLoadResult.newIds as? [Int64], !newIds.isEmpty { updatedRequirements.interactionIdsNeedingFetch.insert(contentsOf: Set(newIds)) } + + /// Fetch any associated data that isn't already cached + if let thread: SessionThread = updatedCache.thread(for: threadId) { + switch thread.variant { + case .contact: + if updatedCache.profile(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.profileIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.contact(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.contactIdsNeedingFetch.insert(thread.id) + } + + case .group, .legacyGroup: + if updatedCache.group(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingFetch.insert(thread.id) + } + + if updatedCache.groupMembers(for: thread.id).isEmpty || currentCache.context.requireFullRefresh { + updatedRequirements.groupIdsNeedingMemberFetch.insert(thread.id) + } + + case .community: + if updatedCache.community(for: thread.id) == nil || currentCache.context.requireFullRefresh { + updatedRequirements.communityIdsNeedingFetch.insert(thread.id) + } + } + } } /// Now that we've finished the page load we can clear out the "insertedIds" sets (should only be used for the above) From 5abc492645c35a0b9d50d601df123d8715731145 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 5 Jan 2026 08:44:00 +1100 Subject: [PATCH 64/66] Fixed an issue where accepting a message request wouldn't refresh linked devices --- .../Types/ConversationDataHelper.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/SessionMessagingKit/Types/ConversationDataHelper.swift b/SessionMessagingKit/Types/ConversationDataHelper.swift index 292cd9f4ef..f35a95dc72 100644 --- a/SessionMessagingKit/Types/ConversationDataHelper.swift +++ b/SessionMessagingKit/Types/ConversationDataHelper.swift @@ -153,6 +153,16 @@ public extension ConversationDataHelper { case (_, let reactionEvent as ReactionEvent): requirements.interactionIdsNeedingReactionUpdates.insert(reactionEvent.messageId) + + case (GenericObservableKey(.messageRequestAccepted), let contactId as String): + switch currentCache.context.source { + case .searchResults: break + case .conversationList: requirements.contactIdsNeedingFetch.insert(contactId) + case .conversationSettings(let threadId), .messageList(let threadId): + guard threadId == contactId else { break } + + requirements.contactIdsNeedingFetch.insert(contactId) + } default: break } @@ -216,6 +226,12 @@ public extension ConversationDataHelper { return true } + /// If a message request was accepted then we need to reload the paged data as it likely means a new + /// conversation should appear at the top of the list + if changes.contains(.messageRequestAccepted) { + return true + } + /// On the conversation list if the last message was deleted then we need to reload the paged data as it means /// the conversation order likely changed for key in itemCache.keys { From 6e0013ef2f708589ea2bd4cb790e2fe57931fa6b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 5 Jan 2026 10:47:20 +1100 Subject: [PATCH 65/66] Updated token pricing to always round up --- .../SessionPro/Utilities/SessionPro+Convenience.swift | 4 ++-- .../SessionNetworkScreen+Models.swift | 8 ++++---- SessionUIKit/Utilities/Number+Utilities.swift | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift index ec5648f82d..175c9bf783 100644 --- a/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift +++ b/SessionMessagingKit/SessionPro/Utilities/SessionPro+Convenience.swift @@ -45,8 +45,8 @@ public extension SessionProPaymentScreenContent.SessionProPlanInfo { init(plan: SessionPro.Plan) { let price: Double = Double(truncating: plan.price as NSNumber) let pricePerMonth: Double = Double(truncating: plan.pricePerMonth as NSNumber) - let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true)) - let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true)) + let formattedPrice: String = price.formatted(format: .currency(decimal: true, withLocalSymbol: true, roundingMode: .floor)) + let formattedPricePerMonth: String = pricePerMonth.formatted(format: .currency(decimal: true, withLocalSymbol: true, roundingMode: .floor)) self = SessionProPaymentScreenContent.SessionProPlanInfo( id: plan.id, diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift index d71f5a3bff..4495616046 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift @@ -37,13 +37,13 @@ public extension SessionNetworkScreenContent { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false, roundingMode: .ceiling))) USD" } public var tokenUSDNoCentsString: String { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" } public var tokenUSDAbbreviatedString: String { guard let tokenUSD: Double = tokenUSD else { @@ -72,7 +72,7 @@ public extension SessionNetworkScreenContent { guard networkStakedUSD > 0 else { return DataModel.defaultPriceString } - return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" } public var networkStakedUSDAbbreviatedString: String { guard networkStakedUSD > 0 else { @@ -92,7 +92,7 @@ public extension SessionNetworkScreenContent { guard let marketCap: Double = marketCapUSD else { return "unavailable".localized() } - return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false))) USD" + return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" } public var marketCapAbbreviatedString: String { guard let marketCap: Double = marketCapUSD else { diff --git a/SessionUIKit/Utilities/Number+Utilities.swift b/SessionUIKit/Utilities/Number+Utilities.swift index 4b1a6d2f10..dd18545c03 100644 --- a/SessionUIKit/Utilities/Number+Utilities.swift +++ b/SessionUIKit/Utilities/Number+Utilities.swift @@ -5,7 +5,7 @@ import Foundation public enum NumberFormat { case abbreviated(decimalPlaces: Int, omitZeroDecimal: Bool) case decimal - case currency(decimal: Bool, withLocalSymbol: Bool) + case currency(decimal: Bool, withLocalSymbol: Bool, roundingMode: NumberFormatter.RoundingMode) case abbreviatedCurrency(decimalPlaces: Int, omitZeroDecimal: Bool) } @@ -31,7 +31,7 @@ public extension NumberFormat { formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "\(value)" - case .currency(let decimal, let withLocalSymbol): + case .currency(let decimal, let withLocalSymbol, let roundingMode): let formatter = NumberFormatter() formatter.numberStyle = .currency if !withLocalSymbol { @@ -41,7 +41,7 @@ public extension NumberFormat { formatter.minimumFractionDigits = 0 formatter.maximumFractionDigits = 0 } - formatter.roundingMode = .floor + formatter.roundingMode = roundingMode return formatter.string(from: NSNumber(value: value)) ?? "\(value)" case .abbreviatedCurrency(let decimalPlaces, let omitZeroDecimal): From a792c6289f994f5198eecc476f7cf35a60ff18fe Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 5 Jan 2026 10:52:42 +1100 Subject: [PATCH 66/66] Tweaked rounding to only round up if on a "5" value (instead of always) --- .../SessionNetworkScreen+Models.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift index 4495616046..9a4b770e93 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen+Models.swift @@ -37,13 +37,13 @@ public extension SessionNetworkScreenContent { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false, roundingMode: .ceiling))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: true, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var tokenUSDNoCentsString: String { guard let tokenUSD: Double = tokenUSD else { return "unavailable".localized() } - return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" + return "$\(tokenUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var tokenUSDAbbreviatedString: String { guard let tokenUSD: Double = tokenUSD else { @@ -72,7 +72,7 @@ public extension SessionNetworkScreenContent { guard networkStakedUSD > 0 else { return DataModel.defaultPriceString } - return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" + return "$\(networkStakedUSD.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var networkStakedUSDAbbreviatedString: String { guard networkStakedUSD > 0 else { @@ -92,7 +92,7 @@ public extension SessionNetworkScreenContent { guard let marketCap: Double = marketCapUSD else { return "unavailable".localized() } - return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .ceiling))) USD" + return "$\(marketCap.formatted(format: .currency(decimal: false, withLocalSymbol: false, roundingMode: .halfUp))) USD" } public var marketCapAbbreviatedString: String { guard let marketCap: Double = marketCapUSD else {