426 lines
17 KiB
Swift
426 lines
17 KiB
Swift
//
|
|
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Wraps a TSOutgoingMessage that is unprepared for sending;
|
|
/// it hasn't been inserted, its attachments haven't been inserted, nothing.
|
|
///
|
|
/// Just a wrapper for the message object and metadata needed
|
|
/// to prepare for sending.
|
|
public class UnpreparedOutgoingMessage {
|
|
|
|
// MARK: - Constructors
|
|
|
|
public static func forMessage(
|
|
_ message: TSOutgoingMessage,
|
|
body: ValidatedMessageBody?,
|
|
unsavedBodyMediaAttachments: [AttachmentDataSource] = [],
|
|
linkPreviewDraft: LinkPreviewDataSource? = nil,
|
|
quotedReplyDraft: DraftQuotedReplyModel.ForSending? = nil,
|
|
messageStickerDraft: MessageStickerDataSource? = nil,
|
|
contactShareDraft: ContactShareDraft.ForSending? = nil,
|
|
poll: CreatePollMessage? = nil,
|
|
) -> UnpreparedOutgoingMessage {
|
|
let oversizeTextDataSource = (body?.oversizeText).map {
|
|
AttachmentDataSource.pendingAttachment($0)
|
|
}
|
|
// TODO: Split these methods once TSOutgoingMessage is no longer the superclass.
|
|
if let message = message as? TransientOutgoingMessage {
|
|
owsAssertDebug(
|
|
unsavedBodyMediaAttachments.isEmpty
|
|
&& oversizeTextDataSource == nil
|
|
&& linkPreviewDraft != nil
|
|
&& quotedReplyDraft != nil
|
|
&& messageStickerDraft != nil,
|
|
"Unknown unsaved message sent through saved path with attachments!",
|
|
)
|
|
Self.assertIsAllowedTransientMessage(message)
|
|
return .init(messageType: .transient(message))
|
|
} else {
|
|
owsPrecondition(message.shouldBeSaved)
|
|
return .init(messageType: .persistable(.init(
|
|
message: message,
|
|
unsavedBodyMediaAttachments: unsavedBodyMediaAttachments,
|
|
oversizeTextDataSource: oversizeTextDataSource,
|
|
linkPreviewDraft: linkPreviewDraft,
|
|
quotedReplyDraft: quotedReplyDraft,
|
|
messageStickerDraft: messageStickerDraft,
|
|
contactShareDraft: contactShareDraft,
|
|
poll: poll,
|
|
)))
|
|
}
|
|
}
|
|
|
|
public static func forEditMessage(
|
|
targetMessage: TSOutgoingMessage,
|
|
edits: MessageEdits,
|
|
oversizeTextDataSource: AttachmentDataSource?,
|
|
linkPreviewDraft: LinkPreviewDataSource?,
|
|
quotedReplyEdit: MessageEdits.Edit<Void>,
|
|
) -> UnpreparedOutgoingMessage {
|
|
return .init(messageType: .editMessage(.init(
|
|
targetMessage: targetMessage,
|
|
edits: edits,
|
|
oversizeTextDataSource: oversizeTextDataSource,
|
|
linkPreviewDraft: linkPreviewDraft,
|
|
quotedReplyEdit: quotedReplyEdit,
|
|
)))
|
|
}
|
|
|
|
public static func forOutgoingStoryMessage(
|
|
_ message: OutgoingStoryMessage,
|
|
storyMessageRowId: Int64,
|
|
) -> UnpreparedOutgoingMessage {
|
|
return .init(messageType: .story(.init(
|
|
message: message,
|
|
storyMessageRowId: storyMessageRowId,
|
|
)))
|
|
}
|
|
|
|
// MARK: - Preparation
|
|
|
|
/// "Prepares" the outgoing message, inserting it into the database if needed and
|
|
/// returning a ``PreparedOutgoingMessage`` ready to be sent.
|
|
public func prepare(
|
|
tx: DBWriteTransaction,
|
|
) throws -> PreparedOutgoingMessage {
|
|
return try self._prepare(tx: tx)
|
|
}
|
|
|
|
public var messageTimestampForLogging: UInt64 {
|
|
switch messageType {
|
|
case .persistable(let message):
|
|
return message.message.timestamp
|
|
case .editMessage(let message):
|
|
return message.edits.timestamp.unwrapChange(
|
|
orKeepValue: message.targetMessage.timestamp,
|
|
)
|
|
case .story(let story):
|
|
return story.message.timestamp
|
|
case .transient(let message):
|
|
return message.timestamp
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private enum MessageType {
|
|
|
|
/// The message that will be inserted into the Interaction table before sending.
|
|
case persistable(Persistable)
|
|
|
|
/// An edit for an existing message; persisted to the Interaction table, but as an edit.
|
|
case editMessage(EditMessage)
|
|
|
|
/// An OutgoingStoryMessage: a TSMessage subclass we use for sending a ``StoryMessage``
|
|
/// The StoryMessage is persisted to the StoryMessages table and is the owner for any attachments;
|
|
/// the OutgoingStoryMessage is _not_ persisted to the Interactions table.
|
|
case story(Story)
|
|
|
|
/// Catch-all for messages not persisted to the Interactions table. The
|
|
/// MessageSender will not upload any attachments contained within these
|
|
/// messages; callers are responsible for uploading them.
|
|
case transient(TransientOutgoingMessage)
|
|
|
|
struct Persistable {
|
|
let message: TSOutgoingMessage
|
|
let unsavedBodyMediaAttachments: [AttachmentDataSource]
|
|
let oversizeTextDataSource: AttachmentDataSource?
|
|
let linkPreviewDraft: LinkPreviewDataSource?
|
|
let quotedReplyDraft: DraftQuotedReplyModel.ForSending?
|
|
let messageStickerDraft: MessageStickerDataSource?
|
|
let contactShareDraft: ContactShareDraft.ForSending?
|
|
let poll: CreatePollMessage?
|
|
}
|
|
|
|
struct EditMessage {
|
|
let targetMessage: TSOutgoingMessage
|
|
let edits: MessageEdits
|
|
let oversizeTextDataSource: AttachmentDataSource?
|
|
let linkPreviewDraft: LinkPreviewDataSource?
|
|
let quotedReplyEdit: MessageEdits.Edit<Void>
|
|
}
|
|
|
|
struct Story {
|
|
let message: OutgoingStoryMessage
|
|
let storyMessageRowId: Int64
|
|
}
|
|
}
|
|
|
|
private let messageType: MessageType
|
|
|
|
private init(messageType: MessageType) {
|
|
self.messageType = messageType
|
|
}
|
|
|
|
public func _prepare(
|
|
tx: DBWriteTransaction,
|
|
) throws -> PreparedOutgoingMessage {
|
|
let preparedMessageType: PreparedOutgoingMessage.MessageType
|
|
switch messageType {
|
|
case .persistable(let message):
|
|
preparedMessageType = try preparePersistableMessage(message, tx: tx)
|
|
case .editMessage(let message):
|
|
preparedMessageType = try prepareEditMessage(message, tx: tx)
|
|
case .story(let story):
|
|
preparedMessageType = prepareStoryMessage(story)
|
|
case .transient(let message):
|
|
preparedMessageType = prepareTransientMessage(message)
|
|
}
|
|
|
|
let builder = PreparedMessageBuilder(messageType: preparedMessageType)
|
|
return PreparedOutgoingMessage(builder)
|
|
}
|
|
|
|
struct PreparedMessageBuilder {
|
|
let messageType: PreparedOutgoingMessage.MessageType
|
|
|
|
// Only this class can have access to this initializer, which in
|
|
// turns means only this class can create a PreparedOutgoingMessage
|
|
fileprivate init(messageType: PreparedOutgoingMessage.MessageType) {
|
|
self.messageType = messageType
|
|
}
|
|
}
|
|
|
|
private func preparePersistableMessage(
|
|
_ message: MessageType.Persistable,
|
|
tx: DBWriteTransaction,
|
|
) throws -> PreparedOutgoingMessage.MessageType {
|
|
let attachmentManager = DependenciesBridge.shared.attachmentManager
|
|
let contactShareManager = DependenciesBridge.shared.contactShareManager
|
|
let linkPreviewManager = DependenciesBridge.shared.linkPreviewManager
|
|
let messageStickerManager = DependenciesBridge.shared.messageStickerManager
|
|
let quotedReplyManager = DependenciesBridge.shared.quotedReplyManager
|
|
|
|
guard
|
|
let thread = message.message.thread(tx: tx),
|
|
let threadRowId = thread.sqliteRowId
|
|
else {
|
|
throw OWSAssertionError("Outgoing message missing thread.")
|
|
}
|
|
|
|
let validatedLinkPreview = try message.linkPreviewDraft.map {
|
|
return try linkPreviewManager.validateDataSource(dataSource: $0, tx: tx)
|
|
}
|
|
if let validatedLinkPreview {
|
|
message.message.update(with: validatedLinkPreview.preview, transaction: tx)
|
|
}
|
|
|
|
let validatedQuotedReply = message.quotedReplyDraft.map {
|
|
return quotedReplyManager.prepareQuotedReplyForSending(draft: $0, tx: tx)
|
|
}
|
|
if let validatedQuotedReply {
|
|
message.message.update(with: validatedQuotedReply.quotedReply, transaction: tx)
|
|
}
|
|
|
|
let validatedMessageSticker = try message.messageStickerDraft.map {
|
|
return try messageStickerManager.validateMessageSticker(dataSource: $0)
|
|
}
|
|
if let validatedMessageSticker {
|
|
message.message.update(with: validatedMessageSticker.sticker, transaction: tx)
|
|
}
|
|
|
|
let validatedContactShare = message.contactShareDraft.map {
|
|
contactShareManager.validateAndBuild(preparedDraft: $0)
|
|
}
|
|
if let validatedContactShare {
|
|
message.message.update(withContactShare: validatedContactShare.contact, transaction: tx)
|
|
}
|
|
|
|
if message.poll != nil {
|
|
message.message.update(withIsPoll: true, transaction: tx)
|
|
}
|
|
|
|
message.message.anyInsert(transaction: tx)
|
|
guard let messageRowId = message.message.sqliteRowId else {
|
|
// We failed to insert!
|
|
throw OWSAssertionError("Failed to insert message!")
|
|
}
|
|
|
|
if let poll = message.poll {
|
|
try DependenciesBridge.shared.pollMessageManager.processOutgoingPollCreate(
|
|
interactionId: messageRowId,
|
|
pollOptions: poll.options,
|
|
allowsMultiSelect: poll.allowMultiple,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
|
|
if let oversizeTextDataSource = message.oversizeTextDataSource {
|
|
let attachmentID = try DependenciesBridge.shared.attachmentManager.createAttachmentStream(
|
|
from: OwnedAttachmentDataSource(
|
|
dataSource: oversizeTextDataSource,
|
|
owner: .messageOversizeText(.init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
)),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created oversize-text attachment \(attachmentID) for outgoing message \(message.message.timestamp)")
|
|
}
|
|
|
|
for (idx, var unsavedBodyMediaAttachment) in message.unsavedBodyMediaAttachments.enumerated() {
|
|
// Borderless is disallowed on any message with a quoted reply.
|
|
if validatedQuotedReply != nil {
|
|
unsavedBodyMediaAttachment = unsavedBodyMediaAttachment.removeBorderlessRenderingFlagIfPresent()
|
|
}
|
|
|
|
let attachmentManager = DependenciesBridge.shared.attachmentManager
|
|
let attachmentID = try attachmentManager.createAttachmentStream(
|
|
from: OwnedAttachmentDataSource(
|
|
dataSource: unsavedBodyMediaAttachment,
|
|
owner: .messageBodyAttachment(.init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isViewOnce: message.message.isViewOnceMessage,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
orderInMessage: UInt32(idx),
|
|
)),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created body attachment \(attachmentID) (idx \(idx)) for outgoing message \(message.message.timestamp)")
|
|
}
|
|
|
|
if let linkPreviewImageDataSource = validatedLinkPreview?.imageDataSource {
|
|
let attachmentID = try attachmentManager.createAttachmentStream(
|
|
from: OwnedAttachmentDataSource(
|
|
dataSource: linkPreviewImageDataSource,
|
|
owner: .messageLinkPreview(.init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
)),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created link preview attachment \(attachmentID) for outgoing message \(message.message.timestamp)")
|
|
}
|
|
|
|
if let thumbnailDataSource = validatedQuotedReply?.thumbnailDataSource {
|
|
let attachmentID = try attachmentManager.createQuotedReplyMessageThumbnail(
|
|
from: thumbnailDataSource,
|
|
owningMessageAttachmentBuilder: .init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created quoted-reply thumbnail attachment \(attachmentID) for outgoing message \(message.message.timestamp)")
|
|
}
|
|
|
|
if let validatedMessageSticker {
|
|
let attachmentID = try attachmentManager.createAttachmentStream(
|
|
from: OwnedAttachmentDataSource(
|
|
dataSource: validatedMessageSticker.attachmentDataSource,
|
|
owner: .messageSticker(.init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
stickerPackId: validatedMessageSticker.sticker.packId,
|
|
stickerId: validatedMessageSticker.sticker.stickerId,
|
|
)),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created sticker attachment \(attachmentID) for outgoing message \(message.message.timestamp)")
|
|
|
|
StickerManager.stickerWasSent(
|
|
validatedMessageSticker.sticker.info,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
|
|
if let avatarDataSource = validatedContactShare?.avatarDataSource {
|
|
let attachmentID = try attachmentManager.createAttachmentStream(
|
|
from: OwnedAttachmentDataSource(
|
|
dataSource: avatarDataSource,
|
|
owner: .messageContactAvatar(.init(
|
|
messageRowId: messageRowId,
|
|
receivedAtTimestamp: message.message.receivedAtTimestamp,
|
|
threadRowId: threadRowId,
|
|
isPastEditRevision: message.message.isPastEditRevision(),
|
|
)),
|
|
),
|
|
tx: tx,
|
|
)
|
|
Logger.info("Created contact avatar attachment \(attachmentID) for outgoing message \(message.message.timestamp)")
|
|
}
|
|
|
|
return .persisted(PreparedOutgoingMessage.MessageType.Persisted(
|
|
rowId: messageRowId,
|
|
message: message.message,
|
|
))
|
|
}
|
|
|
|
private func prepareEditMessage(
|
|
_ message: MessageType.EditMessage,
|
|
tx: DBWriteTransaction,
|
|
) throws -> PreparedOutgoingMessage.MessageType {
|
|
guard let thread = message.targetMessage.thread(tx: tx) else {
|
|
throw OWSAssertionError("Outgoing message missing thread.")
|
|
}
|
|
|
|
let outgoingEditMessage = try DependenciesBridge.shared.editManager.createOutgoingEditMessage(
|
|
targetMessage: message.targetMessage,
|
|
thread: thread,
|
|
edits: message.edits,
|
|
oversizeText: message.oversizeTextDataSource,
|
|
quotedReplyEdit: message.quotedReplyEdit,
|
|
linkPreview: message.linkPreviewDraft,
|
|
tx: tx,
|
|
)
|
|
|
|
// All editable messages, by definition, should have been inserted.
|
|
// Fail if we have no row id.
|
|
let editedMessage = outgoingEditMessage.editedMessage
|
|
guard let editedMessageRowId = editedMessage.sqliteRowId else {
|
|
// We failed to insert!
|
|
throw OWSAssertionError("Failed to insert message!")
|
|
}
|
|
|
|
return .editMessage(.init(
|
|
editedMessageRowId: editedMessageRowId,
|
|
editedMessage: editedMessage,
|
|
messageForSending: outgoingEditMessage,
|
|
))
|
|
}
|
|
|
|
private func prepareStoryMessage(
|
|
_ story: MessageType.Story,
|
|
) -> PreparedOutgoingMessage.MessageType {
|
|
return .story(PreparedOutgoingMessage.MessageType.Story(
|
|
message: story.message,
|
|
))
|
|
}
|
|
|
|
private func prepareTransientMessage(
|
|
_ message: TransientOutgoingMessage,
|
|
) -> PreparedOutgoingMessage.MessageType {
|
|
return .transient(message)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
static func assertIsAllowedTransientMessage(_ message: TSOutgoingMessage) {
|
|
owsAssertDebug(
|
|
!message.shouldBeSaved
|
|
&& !(message is OWSSyncContactsMessage)
|
|
&& !(message is OutgoingStoryMessage)
|
|
&& !(message is OutgoingEditMessage),
|
|
"Disallowed transient message; use type-specific initializers instead",
|
|
)
|
|
}
|
|
}
|