You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionMessagingKit/Sending & Receiving/MessageSender.swift

841 lines
37 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
import Sodium
public final class MessageSender {
// MARK: - Message Preparation
public static func preparedSend(
_ db: Database,
message: Message,
to destination: Message.Destination,
namespace: SnodeAPI.Namespace?,
interactionId: Int64?,
fileIds: [String],
isSyncMessage: Bool = false,
using dependencies: Dependencies = Dependencies()
) throws -> HTTP.PreparedRequest<Void> {
// Common logic for all destinations
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies)
let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs(using: dependencies)
let updatedMessage: Message = message
// Set the message 'sentTimestamp' (Visible messages will already have their sent timestamp set)
updatedMessage.sentTimestamp = (
updatedMessage.sentTimestamp ??
UInt64(messageSendTimestamp)
)
do {
switch destination {
case .contact, .closedGroup:
return try preparedSendToSnodeDestination(
db,
message: updatedMessage,
to: destination,
namespace: namespace,
interactionId: interactionId,
fileIds: fileIds,
userPublicKey: currentUserPublicKey,
messageSendTimestamp: messageSendTimestamp,
isSyncMessage: isSyncMessage,
using: dependencies
)
.map { _, _ in () }
case .openGroup:
return try preparedSendToOpenGroupDestination(
db,
message: updatedMessage,
to: destination,
interactionId: interactionId,
fileIds: fileIds,
messageSendTimestamp: messageSendTimestamp,
using: dependencies
)
case .openGroupInbox:
return try preparedSendToOpenGroupInboxDestination(
db,
message: message,
to: destination,
interactionId: interactionId,
fileIds: fileIds,
userPublicKey: currentUserPublicKey,
messageSendTimestamp: messageSendTimestamp,
using: dependencies
)
}
}
catch let error as MessageSenderError {
throw MessageSender.handleFailedMessageSend(
db,
message: message,
with: error,
interactionId: interactionId,
isSyncMessage: isSyncMessage,
using: dependencies
)
}
}
internal static func preparedSendToSnodeDestination(
_ db: Database,
message: Message,
to destination: Message.Destination,
namespace: SnodeAPI.Namespace?,
interactionId: Int64?,
fileIds: [String],
userPublicKey: String,
messageSendTimestamp: Int64,
isSyncMessage: Bool = false,
using dependencies: Dependencies
) throws -> HTTP.PreparedRequest<SendMessagesResponse> {
guard let namespace: SnodeAPI.Namespace = namespace else { throw MessageSenderError.invalidMessage }
/// Set the sender/recipient info (needed to be valid)
///
/// **Note:** The `sentTimestamp` will differ from the `messageSendTimestamp` as it's the time the user originally
/// sent the message whereas the `messageSendTimestamp` is the time it will be uploaded to the swarm
let sentTimestamp: UInt64 = (message.sentTimestamp ?? UInt64(messageSendTimestamp))
let recipient: String = {
switch destination {
case .contact(let publicKey): return publicKey
case .closedGroup(let groupPublicKey): return groupPublicKey
case .openGroup, .openGroupInbox: preconditionFailure()
}
}()
message.sender = userPublicKey
message.recipient = recipient
message.sentTimestamp = sentTimestamp
// Ensure the message is valid
try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds)
// Attach the user's profile if needed (no need to do so for 'Note to Self' or sync
// messages as they will be managed by the user config handling
let isSelfSend: Bool = (recipient == userPublicKey)
if !isSelfSend, !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile {
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl {
messageWithProfile.profile = VisibleMessage.VMProfile(
displayName: profile.name,
profileKey: profileKey,
profilePictureUrl: profilePictureUrl
)
}
else {
messageWithProfile.profile = VisibleMessage.VMProfile(displayName: profile.name)
}
}
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId, isSyncMessage: isSyncMessage)
// Convert it to protobuf
let threadId: String = Message.threadId(forMessage: message, destination: destination)
guard let proto = message.toProto(db, threadId: threadId) else {
throw MessageSenderError.protoConversionFailed
}
// Serialize the protobuf
let plaintext: Data = try Result(proto.serializedData())
.map { serialisedData -> Data in
switch destination {
case .closedGroup(let key) where SessionId.Prefix(from: key) == .group: return serialisedData
default: return serialisedData.paddedMessageBody()
}
}
.mapError { MessageSenderError.other("Couldn't serialize proto", $0) }
.successOrThrow()
let base64EncodedData: String = try {
switch (destination, namespace) {
// Standard one-to-one messages
case (.contact(let publicKey), .default):
let ciphertext: Data = try encryptWithSessionProtocol(
db,
plaintext: plaintext,
for: publicKey,
using: dependencies
)
return try Result(
MessageWrapper.wrap(
type: .sessionMessage,
timestamp: sentTimestamp,
senderPublicKey: "",
base64EncodedContent: ciphertext.base64EncodedString()
)
)
.mapError { MessageSenderError.other("Couldn't wrap message", $0) }
.successOrThrow()
.base64EncodedString()
// Updated group messages should be wrapped _before_ encrypting
case (.closedGroup(let groupPublicKey), .groupMessages) where SessionId.Prefix(from: groupPublicKey) == .group:
return try SessionUtil
.encrypt(
message: try Result(
MessageWrapper.wrap(
type: .closedGroupMessage,
timestamp: sentTimestamp,
senderPublicKey: groupPublicKey,
base64EncodedContent: plaintext.base64EncodedString(),
wrapInWebSocketMessage: false
)
)
.mapError { MessageSenderError.other("Couldn't wrap message", $0) }
.successOrThrow(),
groupIdentityPublicKey: groupPublicKey,
using: dependencies
)
.base64EncodedString()
// Config messages should be sent directly rather than via this method
case (.contact, _): throw MessageSenderError.invalidConfigMessageHandling
case (.closedGroup(let groupPublicKey), _) where SessionId.Prefix(from: groupPublicKey) == .group:
throw MessageSenderError.invalidConfigMessageHandling
// Legacy groups used a `05` prefix
case (.closedGroup(let groupPublicKey), _):
guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else {
throw MessageSenderError.noKeyPair
}
let ciphertext: Data = try encryptWithSessionProtocol(
db,
plaintext: plaintext,
for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString,
using: dependencies
)
return try Result(
MessageWrapper.wrap(
type: .closedGroupMessage,
timestamp: sentTimestamp,
senderPublicKey: groupPublicKey,
base64EncodedContent: ciphertext.base64EncodedString()
)
)
.mapError { MessageSenderError.other("Couldn't wrap message", $0) }
.successOrThrow()
.base64EncodedString()
case (.openGroup, _), (.openGroupInbox, _): preconditionFailure()
}
}()
// Send the result
let snodeMessage = SnodeMessage(
recipient: recipient,
data: base64EncodedData,
ttl: MessageSender
.getSpecifiedTTL(db, threadId: threadId, message: message, isSyncMessage: isSyncMessage)
.defaulting(to: message.ttl),
timestampMs: UInt64(messageSendTimestamp)
)
return try SnodeAPI
.preparedSendMessage(
db,
message: snodeMessage,
in: namespace,
authInfo: try SnodeAPI.AuthenticationInfo(db, threadId: threadId, using: dependencies),
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
let updatedMessage: Message = message
updatedMessage.serverHash = response.hash
let job: Job? = Job(
variant: .notifyPushServer,
behaviour: .runOnce,
details: NotifyPushServerJob.Details(message: snodeMessage)
)
let shouldNotify: Bool = {
// New groups only run via the updated push server so don't notify
switch destination {
case .closedGroup(let key) where SessionId.Prefix(from: key) == .group:
return false
default: break
}
switch updatedMessage {
case is VisibleMessage, is UnsendRequest: return !isSyncMessage
case let callMessage as CallMessage:
// Note: Other 'CallMessage' types are too big to send as push notifications
// so only send the 'preOffer' message as a notification
switch callMessage.kind {
case .preOffer: return true
default: return false
}
default: return false
}
}()
// Save the updated message info and send a PN if needed
dependencies[singleton: .storage].write(using: dependencies) { db -> Void in
try MessageSender.handleSuccessfulMessageSend(
db,
message: updatedMessage,
to: destination,
interactionId: interactionId,
serverTimestampMs: nil, // No server timestamp in snode messages
isSyncMessage: isSyncMessage,
using: dependencies
)
guard shouldNotify else { return }
dependencies[singleton: .jobRunner].add(
db,
job: job,
canStartJob: true,
using: dependencies
)
}
// If we should send a push notification and are sending from the background then
// we want to send it on this thread
guard
let job: Job = job,
shouldNotify &&
!dependencies[defaults: .appGroup, key: .isMainAppActive]
else { return }
NotifyPushServerJob.run(
job,
queue: .main,
success: { _, _, _ in },
failure: { _, _, _, _ in },
deferred: { _, _ in },
using: dependencies
)
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
dependencies[singleton: .storage].read(using: dependencies) { db in
MessageSender.handleFailedMessageSend(
db,
message: message,
with: .other("Couldn't send message", error),
interactionId: interactionId,
isSyncMessage: isSyncMessage,
using: dependencies
)
}
}
}
)
}
private static func preparedSendToOpenGroupDestination(
_ db: Database,
message: Message,
to destination: Message.Destination,
interactionId: Int64?,
fileIds: [String],
messageSendTimestamp: Int64,
using dependencies: Dependencies
) throws -> HTTP.PreparedRequest<Void> {
// Note: It's possible to send a message and then delete the open group you sent the message to
// which would go into this case, so rather than handling it as an invalid state we just want to
// error in a non-retryable way
guard
let message: VisibleMessage = message as? VisibleMessage,
case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, _) = destination,
let openGroup: OpenGroup = try? OpenGroup.fetchOne(
db,
id: OpenGroup.idFor(roomToken: roomToken, server: server)
),
let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db, using: dependencies)
else { throw MessageSenderError.invalidMessage }
// Set the sender/recipient info (needed to be valid)
let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server)
message.recipient = [
server,
roomToken,
whisperTo,
(whisperMods ? "mods" : nil)
]
.compactMap { $0 }
.joined(separator: ".")
message.sender = try {
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: [])
// If the server doesn't support blinding then go with an unblinded id
guard capabilities.isEmpty || capabilities.contains(.blind) else {
return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
}
guard
let blindedKeyPair: KeyPair = dependencies[singleton: .crypto].generate(
.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, using: dependencies)
)
else { throw MessageSenderError.signingFailed }
return SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString
}()
// Ensure the message is valid
try MessageSender.ensureValidMessage(message, destination: destination, fileIds: fileIds)
// Attach the user's profile
message.profile = VisibleMessage.VMProfile(
profile: Profile.fetchOrCreateCurrentUser(db),
blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests]
)
guard !(message.profile?.displayName ?? "").isEmpty else { throw MessageSenderError.noUsername }
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId)
// Convert it to protobuf
guard let proto = message.toProto(db, threadId: threadId) else {
throw MessageSenderError.protoConversionFailed
}
// Serialize the protobuf
let plaintext: Data
do { plaintext = try proto.serializedData().paddedMessageBody() }
catch { throw MessageSenderError.other("Couldn't serialize proto", error) }
return try OpenGroupAPI
.preparedSend(
db,
plaintext: plaintext,
to: roomToken,
on: server,
whisperTo: whisperTo,
whisperMods: whisperMods,
fileIds: fileIds
)
.handleEvents(
receiveOutput: { _, response in
let serverTimestampMs: UInt64? = response.posted.map { UInt64(floor($0 * 1000)) }
let updatedMessage: Message = message
updatedMessage.openGroupServerMessageId = UInt64(response.id)
dependencies[singleton: .storage].write(using: dependencies) { db in
// The `posted` value is in seconds but we sent it in ms so need that for de-duping
try MessageSender.handleSuccessfulMessageSend(
db,
message: updatedMessage,
to: destination,
interactionId: interactionId,
serverTimestampMs: serverTimestampMs,
isSyncMessage: false, // No sync messages in open groups
using: dependencies
)
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
dependencies[singleton: .storage].read(using: dependencies) { db in
MessageSender.handleFailedMessageSend(
db,
message: message,
with: .other("Couldn't send message", error),
interactionId: interactionId,
isSyncMessage: false, // No sync messages in open groups
using: dependencies
)
}
}
}
)
.map { _, _ in () }
}
private static func preparedSendToOpenGroupInboxDestination(
_ db: Database,
message: Message,
to destination: Message.Destination,
interactionId: Int64?,
fileIds: [String],
userPublicKey: String,
messageSendTimestamp: Int64,
using dependencies: Dependencies
) throws -> HTTP.PreparedRequest<Void> {
// The `openGroupInbox` destination does not support attachments
guard
fileIds.isEmpty,
case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination
else { throw MessageSenderError.invalidMessage }
message.sender = userPublicKey
message.recipient = recipientBlindedPublicKey
// Attach the user's profile if needed
if let message: VisibleMessage = message as? VisibleMessage {
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl {
message.profile = VisibleMessage.VMProfile(
displayName: profile.name,
profileKey: profileKey,
profilePictureUrl: profilePictureUrl
)
}
else {
message.profile = VisibleMessage.VMProfile(displayName: profile.name)
}
}
// Perform any pre-send actions
handleMessageWillSend(db, message: message, interactionId: interactionId)
// Convert it to protobuf
guard let proto = message.toProto(db, threadId: recipientBlindedPublicKey) else {
throw MessageSenderError.protoConversionFailed
}
// Serialize the protobuf
let plaintext: Data
do { plaintext = try proto.serializedData().paddedMessageBody() }
catch { throw MessageSenderError.other("Couldn't serialize proto", error) }
// Encrypt the serialized protobuf
let ciphertext: Data
do {
ciphertext = try encryptWithSessionBlindingProtocol(
db,
plaintext: plaintext,
for: recipientBlindedPublicKey,
openGroupPublicKey: openGroupPublicKey,
using: dependencies
)
}
catch { throw MessageSenderError.other("Couldn't encrypt message for destination: \(destination)", error) }
return try OpenGroupAPI
.preparedSend(
db,
ciphertext: ciphertext,
toInboxFor: recipientBlindedPublicKey,
on: server,
using: dependencies
)
.handleEvents(
receiveOutput: { _, response in
let updatedMessage: Message = message
updatedMessage.openGroupServerMessageId = UInt64(response.id)
dependencies[singleton: .storage].write(using: dependencies) { db in
// The `posted` value is in seconds but we sent it in ms so need that for de-duping
try MessageSender.handleSuccessfulMessageSend(
db,
message: updatedMessage,
to: destination,
interactionId: interactionId,
serverTimestampMs: UInt64(floor(response.posted * 1000)),
isSyncMessage: false, // No sync messages in open groups
using: dependencies
)
}
},
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
dependencies[singleton: .storage].read(using: dependencies) { db in
MessageSender.handleFailedMessageSend(
db,
message: message,
with: .other("Couldn't send message", error),
interactionId: interactionId,
isSyncMessage: false, // No sync messages in open groups
using: dependencies
)
}
}
}
)
.map { _, _ in () }
}
// MARK: - Success & Failure Handling
private static func ensureValidMessage(
_ message: Message,
destination: Message.Destination,
fileIds: [String]
) throws {
/// Check the message itself is valid
guard message.isValid else { throw MessageSenderError.invalidMessage }
/// We now allow the creation of message data without validating it's attachments have finished uploading first, this is here to
/// ensure we don't send a message which should have uploaded files
///
/// If you see this error then you need to upload the associated attachments prior to sending the message
if let visibleMessage: VisibleMessage = message as? VisibleMessage {
let expectedAttachmentUploadCount: Int = (
visibleMessage.attachmentIds.count +
(visibleMessage.linkPreview?.attachmentId != nil ? 1 : 0) +
(visibleMessage.quote?.attachmentId != nil ? 1 : 0)
)
guard expectedAttachmentUploadCount == fileIds.count else {
throw MessageSenderError.attachmentsNotUploaded
}
}
}
public static func handleMessageWillSend(
_ db: Database,
message: Message,
interactionId: Int64?,
isSyncMessage: Bool = false
) {
// If the message was a reaction then we don't want to do anything to the original
// interaction (which the 'interactionId' is pointing to
guard (message as? VisibleMessage)?.reaction == nil else { return }
// Mark messages as "sending"/"syncing" if needed (this is for retries)
_ = try? RecipientState
.filter(RecipientState.Columns.interactionId == interactionId)
.filter(isSyncMessage ?
RecipientState.Columns.state == RecipientState.State.failedToSync :
RecipientState.Columns.state == RecipientState.State.failed
)
.updateAll(
db,
RecipientState.Columns.state.set(to: isSyncMessage ?
RecipientState.State.syncing :
RecipientState.State.sending
)
)
}
private static func handleSuccessfulMessageSend(
_ db: Database,
message: Message,
to destination: Message.Destination,
interactionId: Int64?,
serverTimestampMs: UInt64?,
isSyncMessage: Bool,
using dependencies: Dependencies
) throws {
// If the message was a reaction then we want to update the reaction instead of the original
// interaction (which the 'interactionId' is pointing to
if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction {
try Reaction
.filter(Reaction.Columns.interactionId == interactionId)
.filter(Reaction.Columns.authorId == reaction.publicKey)
.filter(Reaction.Columns.emoji == reaction.emoji)
.updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash))
}
else {
// Otherwise we do want to try and update the referenced interaction
let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId)
// Get the visible message if possible
if let interaction: Interaction = interaction {
// Only store the server hash of a sync message if the message is self send valid
if (message.isSelfSendValid && isSyncMessage || !isSyncMessage) {
try interaction.with(
serverHash: message.serverHash,
// Track the open group server message ID and update server timestamp (use server
// timestamp for open group messages otherwise the quote messages may not be able
// to be found by the timestamp on other devices
timestampMs: (message.openGroupServerMessageId == nil ?
nil :
serverTimestampMs.map { Int64($0) }
),
openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }
).update(db)
if
interaction.isExpiringMessage && isSyncMessage,
let startedAtMs: Double = interaction.expiresStartedAtMs,
let expiresInSeconds: TimeInterval = interaction.expiresInSeconds,
let serverHash: String = message.serverHash
{
let expirationTimestampMs: Int64 = Int64(startedAtMs + expiresInSeconds * 1000)
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .expirationUpdate,
behaviour: .runOnce,
threadId: interaction.threadId,
details: ExpirationUpdateJob.Details(
serverHashes: [serverHash],
expirationTimestampMs: expirationTimestampMs
)
),
canStartJob: true,
using: dependencies
)
}
}
// Mark the message as sent
try interaction.recipientStates
.filter(RecipientState.Columns.state != RecipientState.State.sent)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
}
}
// Extract the threadId from the message
let threadId: String = Message.threadId(forMessage: message, destination: destination)
// Prevent ControlMessages from being handled multiple times if not supported
try? ControlMessageProcessRecord(
threadId: threadId,
message: message,
serverExpirationTimestamp: (
(TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) +
ControlMessageProcessRecord.defaultExpirationSeconds
)
)?.insert(db)
// Sync the message if needed
scheduleSyncMessageIfNeeded(
db,
message: message,
destination: destination,
threadId: threadId,
interactionId: interactionId,
isAlreadySyncMessage: isSyncMessage,
using: dependencies
)
}
@discardableResult internal static func handleFailedMessageSend(
_ db: Database,
message: Message,
with error: MessageSenderError,
interactionId: Int64?,
isSyncMessage: Bool,
using dependencies: Dependencies
) -> Error {
// Log a message for any 'other' errors
switch error {
case .other(let description, let error): SNLog("\(description) due to error: \(error).")
default: break
}
// If the message was a reaction then we don't want to do anything to the original
// interaciton (which the 'interactionId' is pointing to
guard (message as? VisibleMessage)?.reaction == nil else { return error }
// Check if we need to mark any "sending" recipients as "failed"
//
// Note: The 'db' could be either read-only or writeable so we determine
// if a change is required, and if so dispatch to a separate queue for the
// actual write
let rowIds: [Int64] = (try? RecipientState
.select(Column.rowID)
.filter(RecipientState.Columns.interactionId == interactionId)
.filter(!isSyncMessage ?
RecipientState.Columns.state == RecipientState.State.sending : (
RecipientState.Columns.state == RecipientState.State.syncing ||
RecipientState.Columns.state == RecipientState.State.sent
)
)
.asRequest(of: Int64.self)
.fetchAll(db))
.defaulting(to: [])
guard !rowIds.isEmpty else { return error }
// Need to dispatch to a different thread to prevent a potential db re-entrancy
// issue from occuring in some cases
DispatchQueue.global(qos: .background).async(using: dependencies) {
dependencies[singleton: .storage].write { db in
try RecipientState
.filter(rowIds.contains(Column.rowID))
.updateAll(
db,
RecipientState.Columns.state.set(
to: (isSyncMessage ? RecipientState.State.failedToSync : RecipientState.State.failed)
),
RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription)
)
}
}
return error
}
// MARK: - Convenience
private static func interaction(_ db: Database, for message: Message, interactionId: Int64?) throws -> Interaction? {
if let interactionId: Int64 = interactionId {
return try Interaction.fetchOne(db, id: interactionId)
}
if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) {
return try Interaction
.filter(Interaction.Columns.timestampMs == sentTimestamp)
.fetchOne(db)
}
return nil
}
public static func scheduleSyncMessageIfNeeded(
_ db: Database,
message: Message,
destination: Message.Destination,
threadId: String?,
interactionId: Int64?,
isAlreadySyncMessage: Bool,
using dependencies: Dependencies
) {
// Sync the message if it's not a sync message, wasn't already sent to the current user and
// it's a message type which should be synced
let currentUserPublicKey = getUserHexEncodedPublicKey(db, using: dependencies)
if
case .contact(let publicKey) = destination,
!isAlreadySyncMessage,
publicKey != currentUserPublicKey,
Message.shouldSync(message: message)
{
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .messageSend,
threadId: threadId,
interactionId: interactionId,
details: MessageSendJob.Details(
destination: .contact(publicKey: currentUserPublicKey),
message: message,
isSyncMessage: true
)
),
canStartJob: true,
using: dependencies
)
}
}
}