Path: blob/main/Signal/src/ViewControllers/ForwardMessageViewController.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
protocol ForwardMessageDelegate: AnyObject {
func forwardMessageFlowDidComplete(items: [ForwardMessageItem], recipientThreads: [TSThread])
func forwardMessageFlowDidCancel()
}
class ForwardMessageViewController: OWSNavigationController {
private let pickerVC: ConversationPickerViewController
weak var forwardMessageDelegate: ForwardMessageDelegate?
private typealias Content = ForwardMessageContent
private var content: Content
private let attachmentLimits: OutgoingAttachmentLimits
private var textMessage: String?
private let selection = ConversationPickerSelection()
var selectedConversations: [ConversationItem] { selection.conversations }
private init(
content: Content,
attachmentLimits: OutgoingAttachmentLimits,
) {
self.content = content
self.attachmentLimits = attachmentLimits
self.pickerVC = ConversationPickerViewController(
selection: selection,
overrideTitle: OWSLocalizedString(
"FORWARD_MESSAGE_TITLE",
comment: "Title for the 'forward message(s)' view.",
),
)
if #available(iOS 26, *) {
self.pickerVC.backgroundStyle = .none
}
super.init()
if self.content.canSendToStories {
if self.content.canSendToNonStories {
self.pickerVC.sectionOptions.insert(.stories)
} else {
self.pickerVC.sectionOptions = .storiesOnly
}
} else {
self.pickerVC.shouldHideRecentConversationsTitle = true
}
pickerVC.pickerDelegate = self
pickerVC.shouldBatchUpdateIdentityKeys = true
viewControllers = [pickerVC]
modalPresentationStyle = .formSheet
sheetPresentationController?.detents = [.medium(), .large()]
sheetPresentationController?.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
class func present(
forItemViewModel itemViewModel: CVItemViewModelImpl,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate,
) {
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
do {
let content: Content = try SSKEnvironment.shared.databaseStorageRef.read { tx in
return try Content.build(itemViewModel: itemViewModel, attachmentLimits: attachmentLimits, tx: tx)
}
present(content: content, from: fromViewController, attachmentLimits: attachmentLimits, delegate: delegate)
} catch {
ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: 1)
}
}
class func present(
forSelectionItems selectionItems: [CVSelectionItem],
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate,
) {
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
do {
let content: Content = try SSKEnvironment.shared.databaseStorageRef.read { tx in
try Content.build(selectionItems: selectionItems, attachmentLimits: attachmentLimits, tx: tx)
}
present(content: content, from: fromViewController, attachmentLimits: attachmentLimits, delegate: delegate)
} catch {
ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: selectionItems.count)
}
}
class func present(
forAttachmentStreams attachmentStreams: [ReferencedAttachmentStream],
fromMessage message: TSMessage,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate,
) {
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
do {
let attachments = try attachmentStreams.map { attachmentStream in
try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream, attachmentLimits: attachmentLimits)
}
present(
content: ForwardMessageContent(allItems: [ForwardMessageItem(interaction: message, attachments: attachments)]),
from: fromViewController,
attachmentLimits: attachmentLimits,
delegate: delegate,
)
} catch let error {
ForwardMessageViewController.showAlertForForwardError(
error: error,
forwardedInteractionCount: 1,
)
}
}
class func present(
forStoryMessage storyMessage: StoryMessage,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate,
) {
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
var attachments: [PreviewableAttachment] = []
var textAttachment: TextAttachment?
switch storyMessage.attachment {
case .media:
let attachment: ReferencedAttachmentStream? = SSKEnvironment.shared.databaseStorageRef.read { tx in
guard
let rowId = storyMessage.id,
let referencedStream = DependenciesBridge.shared.attachmentStore.fetchAnyReferencedAttachment(
for: .storyMessageMedia(storyMessageRowId: rowId),
tx: tx,
)?.asReferencedStream
else {
return nil
}
return referencedStream
}
do {
guard let attachment else {
throw OWSAssertionError("Missing attachment stream for forwarded story message")
}
let signalAttachment = try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachment, attachmentLimits: attachmentLimits)
attachments = [signalAttachment]
} catch let error {
ForwardMessageViewController.showAlertForForwardError(
error: error,
forwardedInteractionCount: 1,
)
return
}
case .text(let _textAttachment):
textAttachment = _textAttachment
}
present(
content: ForwardMessageContent(allItems: [ForwardMessageItem(attachments: attachments, textAttachment: textAttachment)]),
from: fromViewController,
attachmentLimits: attachmentLimits,
delegate: delegate,
)
}
class func present(
forMessageBody messageBody: MessageBody,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate,
) {
present(
content: ForwardMessageContent(allItems: [ForwardMessageItem(messageBody: messageBody)]),
from: fromViewController,
attachmentLimits: .currentLimits(),
delegate: delegate,
)
}
private class func present(
content: Content,
from fromViewController: UIViewController,
attachmentLimits: OutgoingAttachmentLimits,
delegate: ForwardMessageDelegate,
) {
let sheet = ForwardMessageViewController(content: content, attachmentLimits: attachmentLimits)
sheet.forwardMessageDelegate = delegate
fromViewController.present(sheet, animated: true) {
UIApplication.shared.hideKeyboard()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
ensureBottomFooterVisibility()
DispatchQueue.main.async {
self.pickerVC.updateTableMargins()
}
}
private func ensureBottomFooterVisibility() {
AssertIsOnMainThread()
if selectedConversations.allSatisfy({ $0.outgoingMessageType == .storyMessage }) {
pickerVC.approvalTextMode = .none
} else {
let placeholderText = OWSLocalizedString(
"FORWARD_MESSAGE_TEXT_PLACEHOLDER",
comment: "Indicates that the user can add a text message to forwarded messages.",
)
pickerVC.approvalTextMode = .active(placeholderText: placeholderText)
}
pickerVC.shouldHideBottomFooter = selectedConversations.isEmpty
}
private func maximizeHeight() {
sheetPresentationController?.animateChanges {
sheetPresentationController?.selectedDetentIdentifier = .large
}
}
}
extension ForwardMessageViewController: UISheetPresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
forwardMessageDelegate?.forwardMessageFlowDidCancel()
}
func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
// Alleviates an issue where the keyboard layout guide gets positioned
// wrong after swiping between detents.
DispatchQueue.main.async {
sheetPresentationController.animateChanges {
self.pickerVC.bottomFooter?.setNeedsLayout()
self.pickerVC.bottomFooter?.layoutIfNeeded()
}
}
}
}
// MARK: - Sending
extension ForwardMessageViewController {
func sendStep() {
AssertIsOnMainThread()
Task {
await _tryToSend()
}
}
private func _tryToSend() async {
let content = self.content
let textMessage = self.textMessage?.strippedOrNil
let recipientConversations = self.selectedConversations
do {
let outgoingMessageRecipientThreads = try await self.outgoingMessageRecipientThreads(for: recipientConversations)
try SSKEnvironment.shared.databaseStorageRef.write { transaction in
for recipientThread in outgoingMessageRecipientThreads {
// We're sending a message to this thread, approve any pending message request
ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest(
recipientThread,
setDefaultTimerIfNecessary: true,
tx: transaction,
)
}
func hasRenderableContent(interaction: TSInteraction, tx: DBReadTransaction) -> Bool {
guard let message = interaction as? TSMessage else {
return false
}
return message.hasRenderableContent(tx: tx)
}
// Make sure the message and its content haven't been deleted (view-once
// messages, remove delete, disappearing messages, manual deletion, etc.).
for item in content.allItems where item.interaction != nil {
guard
let interactionId = item.interaction?.uniqueId,
let latestInteraction = TSInteraction.anyFetch(
uniqueId: interactionId,
transaction: transaction,
),
hasRenderableContent(interaction: latestInteraction, tx: transaction)
else {
throw ForwardError.missingInteraction
}
}
}
// TODO: Ideally we would enqueue all with a single write transaction.
// Maintain order of interactions.
let sortedItems = content.allItems.sorted { lhs, rhs in
lhs.interaction?.sortId ?? 0 < rhs.interaction?.sortId ?? 0
}
// _Enqueue_ each item serially.
for item in sortedItems {
try await self.send(item: item, toOutgoingMessageRecipientThreads: outgoingMessageRecipientThreads)
}
// The user may have added an additional text message to the forward.
// It should be sent last.
if let textMessage {
let messageBody = MessageBody(text: textMessage, ranges: .empty)
await enqueueMessageViaThreadUtil(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(body: messageBody, recipientThread: recipientThread)
}
}
self.forwardMessageDelegate?.forwardMessageFlowDidComplete(
items: content.allItems,
recipientThreads: outgoingMessageRecipientThreads,
)
} catch {
owsFailDebug("Error: \(error)")
Self.showAlertForForwardError(error: error, forwardedInteractionCount: content.allItems.count)
}
}
private func send(item: ForwardMessageItem, toOutgoingMessageRecipientThreads outgoingMessageRecipientThreads: [TSThread]) async throws {
if let stickerMetadata = item.stickerMetadata {
let stickerInfo = stickerMetadata.stickerInfo
if StickerManager.isStickerInstalled(stickerInfo: stickerInfo) {
await enqueueMessageViaThreadUtil(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(installedSticker: stickerInfo, thread: recipientThread)
}
} else {
guard let stickerAttachment = item.stickerAttachment else {
throw OWSAssertionError("Missing stickerAttachment.")
}
let stickerData = try stickerAttachment.decryptedRawData()
await enqueueMessageViaThreadUtil(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(uninstalledSticker: stickerMetadata, stickerData: stickerData, thread: recipientThread)
}
}
} else if let contactShare = item.contactShare {
await enqueueMessageViaThreadUtil(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(contactShare: contactShare.copyForResending(), thread: recipientThread)
}
} else if !item.attachments.isEmpty {
// TODO: What about link previews in this case?
let conversations = selectedConversations
_ = try await AttachmentMultisend.enqueueApprovedMedia(
conversations: conversations,
approvedMessageBody: item.messageBody,
approvedAttachments: ApprovedAttachments(nonViewOnceAttachments: item.attachments, imageQuality: .high),
attachmentLimits: attachmentLimits,
)
} else if let textAttachment = item.textAttachment {
// TODO: we want to reuse the uploaded link preview image attachment instead of re-uploading
// if the original was sent recently (if not the image could be stale)
_ = try await AttachmentMultisend.enqueueTextAttachment(
textAttachment.asUnsentAttachment(),
to: selectedConversations,
)
} else if let messageBody = item.messageBody {
let linkPreviewDraft = item.linkPreviewDraft
await enqueueMessageViaThreadUtil(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(body: messageBody, linkPreviewDraft: linkPreviewDraft, recipientThread: recipientThread)
}
// Send the text message to any selected story recipients as a text story
// with default styling.
_ = try await StorySharing.enqueueTextStory(with: messageBody, linkPreviewDraft: linkPreviewDraft, to: selectedConversations)
} else {
throw ForwardError.invalidInteraction
}
}
private func send(body: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft? = nil, recipientThread: TSThread) {
let body = SSKEnvironment.shared.databaseStorageRef.read { transaction in
return body.forForwarding(to: recipientThread, transaction: transaction).asMessageBodyForForwarding()
}
ThreadUtil.enqueueMessage(
body: body,
thread: recipientThread,
linkPreviewDraft: linkPreviewDraft,
)
}
private func send(contactShare: ContactShareDraft, thread: TSThread) {
ThreadUtil.enqueueMessage(withContactShare: contactShare, thread: thread)
}
private func send(installedSticker stickerInfo: StickerInfo, thread: TSThread) {
ThreadUtil.enqueueMessage(withInstalledSticker: stickerInfo, thread: thread)
}
private func send(uninstalledSticker stickerMetadata: any StickerMetadata, stickerData: Data, thread: TSThread) {
ThreadUtil.enqueueMessage(withUninstalledSticker: stickerMetadata, stickerData: stickerData, thread: thread)
}
private func enqueueMessageViaThreadUtil(
toRecipientThreads recipientThreads: [TSThread],
enqueueBlock: (TSThread) -> Void,
) async {
for recipientThread in recipientThreads {
enqueueBlock(recipientThread)
}
// This should be changed in the future, but waiting on this queue will
// ensure that `enqueueBlock` (the prior line) has finished its work.
try? await ThreadUtil.enqueueSendQueue.enqueue(operation: {}).value
}
private func outgoingMessageRecipientThreads(for conversationItems: [ConversationItem]) async throws -> [TSThread] {
guard conversationItems.count > 0 else {
throw OWSAssertionError("No recipients.")
}
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
return try await databaseStorage.awaitableWrite { transaction in
try conversationItems.lazy.filter { $0.outgoingMessageType == .message }.map {
guard let thread = $0.getOrCreateThread(transaction: transaction) else {
throw ForwardError.missingThread
}
return thread
}
}
}
}
// MARK: -
extension ForwardMessageViewController: ConversationPickerDelegate {
func conversationPickerSelectionDidChange(_ conversationPickerViewController: ConversationPickerViewController) {
ensureBottomFooterVisibility()
}
func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController) {
self.textMessage = conversationPickerViewController.textInput?.strippedOrNil
sendStep()
}
func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool {
true
}
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController) {
forwardMessageDelegate?.forwardMessageFlowDidCancel()
}
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode {
.send
}
func conversationPickerDidBeginEditingText() {
AssertIsOnMainThread()
maximizeHeight()
}
func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController) {
maximizeHeight()
}
}
// MARK: -
extension ForwardMessageViewController {
static func finalizeForward(
items: [ForwardMessageItem],
recipientThreads: [TSThread],
fromViewController: UIViewController,
) {
let toast: String
if items.count > 1 {
toast = OWSLocalizedString(
"FORWARD_MESSAGE_MESSAGES_SENT_N",
comment: "Indicates that multiple messages were forwarded.",
)
} else {
toast = OWSLocalizedString(
"FORWARD_MESSAGE_MESSAGES_SENT_1",
comment: "Indicates that a single message was forwarded.",
)
}
if let cvc = fromViewController as? ConversationViewController {
cvc.presentToastCVC(toast, image: .check)
} else {
fromViewController.presentToast(text: toast, image: .check)
}
}
}
// MARK: -
enum ForwardError: Error {
case missingInteraction
case missingThread
case invalidInteraction
}
// MARK: -
extension ForwardMessageViewController {
static func showAlertForForwardError(
error: Error,
forwardedInteractionCount: Int,
) {
let genericErrorMessage = (
forwardedInteractionCount > 1
? OWSLocalizedString(
"ERROR_COULD_NOT_FORWARD_MESSAGES_N",
comment: "Error indicating that messages could not be forwarded.",
)
: OWSLocalizedString(
"ERROR_COULD_NOT_FORWARD_MESSAGES_1",
comment: "Error indicating that a message could not be forwarded.",
),
)
guard let forwardError = error as? ForwardError else {
owsFailDebug("Error: \(error).")
OWSActionSheets.showErrorAlert(message: genericErrorMessage)
return
}
switch forwardError {
case .missingInteraction:
let message = (
forwardedInteractionCount > 1
? OWSLocalizedString(
"ERROR_COULD_NOT_FORWARD_MESSAGES_MISSING_N",
comment: "Error indicating that messages could not be forwarded.",
)
: OWSLocalizedString(
"ERROR_COULD_NOT_FORWARD_MESSAGES_MISSING_1",
comment: "Error indicating that a message could not be forwarded.",
),
)
OWSActionSheets.showErrorAlert(message: message)
case .missingThread, .invalidInteraction:
owsFailDebug("Error: \(error).")
OWSActionSheets.showErrorAlert(message: genericErrorMessage)
}
}
}
// MARK: -
struct ForwardMessageItem {
let interaction: TSInteraction?
let attachments: [PreviewableAttachment]
let contactShare: ContactShareViewModel?
let messageBody: MessageBody?
let linkPreviewDraft: OWSLinkPreviewDraft?
let stickerMetadata: (any StickerMetadata)?
let stickerAttachment: AttachmentStream?
let textAttachment: TextAttachment?
fileprivate init(
interaction: TSInteraction? = nil,
attachments: [PreviewableAttachment] = [],
contactShare: ContactShareViewModel? = nil,
messageBody: MessageBody? = nil,
linkPreviewDraft: OWSLinkPreviewDraft? = nil,
stickerMetadata: (any StickerMetadata)? = nil,
stickerAttachment: AttachmentStream? = nil,
textAttachment: TextAttachment? = nil,
) {
self.interaction = interaction
self.attachments = attachments
self.contactShare = contactShare
self.messageBody = messageBody
self.linkPreviewDraft = linkPreviewDraft
self.stickerMetadata = stickerMetadata
self.stickerAttachment = stickerAttachment
self.textAttachment = textAttachment
}
fileprivate static func build(
interaction: TSInteraction,
componentState: CVComponentState,
selectionType: CVSelectionType,
attachmentLimits: OutgoingAttachmentLimits,
transaction: DBReadTransaction,
) throws -> Self {
let shouldHaveText = (selectionType == .allContent || selectionType == .secondaryContent)
let shouldHaveAttachments = (selectionType == .allContent || selectionType == .primaryContent)
guard shouldHaveText || shouldHaveAttachments else {
throw ForwardError.invalidInteraction
}
var messageBody: MessageBody?
var linkPreviewDraft: OWSLinkPreviewDraft?
if
shouldHaveText,
let displayableBodyText = componentState.displayableBodyText,
!displayableBodyText.fullTextValue.isEmpty
{
switch displayableBodyText.fullTextValue {
case .text(let text):
messageBody = MessageBody(text: text, ranges: .empty)
case .attributedText(let text):
messageBody = MessageBody(text: text.string, ranges: .empty)
case .messageBody(let hydratedBody):
messageBody = hydratedBody.asMessageBodyForForwarding(preservingAllMentions: true)
}
if let linkPreview = componentState.linkPreviewModel, let message = interaction as? TSMessage {
linkPreviewDraft = Self.tryToCloneLinkPreview(
linkPreview: linkPreview,
parentMessage: message,
transaction: transaction,
)
}
}
var attachments: [PreviewableAttachment] = []
var contactShare: ContactShareViewModel?
var stickerMetadata: (any StickerMetadata)?
var stickerAttachment: AttachmentStream?
if shouldHaveAttachments {
if let oldContactShare = componentState.contactShareModel {
contactShare = oldContactShare.copyForRendering()
}
var attachmentStreams = [ReferencedAttachmentStream]()
attachmentStreams.append(contentsOf: componentState.bodyMediaAttachmentStreams)
if let attachmentStream = componentState.audioAttachmentStream {
attachmentStreams.append(attachmentStream)
}
if let attachmentStream = componentState.genericAttachmentStream {
attachmentStreams.append(attachmentStream)
}
attachments = try attachmentStreams.map { attachmentStream in
try SignalAttachmentCloner.cloneAsSignalAttachment(attachment: attachmentStream, attachmentLimits: attachmentLimits)
}
stickerMetadata = componentState.stickerMetadata
stickerAttachment = (stickerMetadata != nil) ? componentState.stickerAttachment : nil
}
let isEmpty: Bool = (
attachments.isEmpty
&& contactShare == nil
&& messageBody == nil
&& stickerMetadata == nil,
)
guard !isEmpty else {
throw ForwardError.invalidInteraction
}
return Self(
interaction: interaction,
attachments: attachments,
contactShare: contactShare,
messageBody: messageBody,
linkPreviewDraft: linkPreviewDraft,
stickerMetadata: stickerMetadata,
stickerAttachment: stickerAttachment,
)
}
private static func tryToCloneLinkPreview(
linkPreview: OWSLinkPreview,
parentMessage: TSMessage,
transaction: DBReadTransaction,
) -> OWSLinkPreviewDraft? {
guard
let urlString = linkPreview.urlString,
let url = URL(string: urlString)
else {
owsFailDebug("Missing or invalid urlString.")
return nil
}
struct LinkPreviewImage {
let imageData: Data
let mimetype: String
static func load(
attachmentId: Attachment.IDType,
transaction: DBReadTransaction,
) -> LinkPreviewImage? {
guard
let attachment = DependenciesBridge.shared.attachmentStore
.fetch(id: attachmentId, tx: transaction)?
.asStream()
else {
owsFailDebug("Missing attachment.")
return nil
}
guard let mimeType = attachment.mimeType.nilIfEmpty else {
owsFailDebug("Missing mimeType.")
return nil
}
do {
let imageData = try attachment.decryptedRawData()
return LinkPreviewImage(imageData: imageData, mimetype: mimeType)
} catch {
owsFailDebug("Error: \(error).")
return nil
}
}
}
var linkPreviewImage: LinkPreviewImage?
if
let parentMessageRowId = parentMessage.sqliteRowId,
let imageAttachmentId = DependenciesBridge.shared.attachmentStore.fetchAnyReference(
owner: .messageLinkPreview(messageRowId: parentMessageRowId),
tx: transaction,
)?.attachmentRowId,
let image = LinkPreviewImage.load(
attachmentId: imageAttachmentId,
transaction: transaction,
)
{
linkPreviewImage = image
}
return OWSLinkPreviewDraft(
url: url,
title: linkPreview.title,
imageData: linkPreviewImage?.imageData,
imageMimeType: linkPreviewImage?.mimetype,
previewDescription: linkPreview.previewDescription,
date: linkPreview.date,
isForwarded: true,
)
}
}
// MARK: -
private struct ForwardMessageContent {
let allItems: [ForwardMessageItem]
var canSendToStories: Bool {
return allItems.allSatisfy { item in
if !item.attachments.isEmpty {
return item.attachments.allSatisfy({ $0.isImage || $0.isVideo })
} else if item.textAttachment != nil {
return true
} else if item.messageBody != nil {
return true
} else {
return false
}
}
}
var canSendToNonStories: Bool {
return allItems.allSatisfy { $0.textAttachment == nil }
}
static func build(
itemViewModel: CVItemViewModelImpl,
attachmentLimits: OutgoingAttachmentLimits,
tx: DBReadTransaction,
) throws -> Self {
return Self(allItems: [try ForwardMessageItem.build(
interaction: itemViewModel.interaction,
componentState: itemViewModel.renderItem.componentState,
selectionType: .allContent,
attachmentLimits: attachmentLimits,
transaction: tx,
)])
}
static func build(
selectionItems: [CVSelectionItem],
attachmentLimits: OutgoingAttachmentLimits,
tx: DBReadTransaction,
) throws -> Self {
let items = try selectionItems.map { selectionItem throws -> ForwardMessageItem in
let interactionId = selectionItem.interactionId
guard let interaction = TSInteraction.fetchViaCache(uniqueId: interactionId, transaction: tx) else {
throw ForwardError.missingInteraction
}
let componentState = try buildComponentState(interaction: interaction, tx: tx)
return try ForwardMessageItem.build(
interaction: interaction,
componentState: componentState,
selectionType: selectionItem.selectionType,
attachmentLimits: attachmentLimits,
transaction: tx,
)
}
return Self(allItems: items)
}
private static func buildComponentState(
interaction: TSInteraction,
tx: DBReadTransaction,
) throws(ForwardError) -> CVComponentState {
guard
let componentState = CVLoader.buildStandaloneComponentState(
interaction: interaction,
spoilerState: SpoilerRenderState(), // Nothing revealed, doesn't matter.
transaction: tx,
)
else {
throw .invalidInteraction
}
return componentState
}
}