Signal-iOS/Signal/src/ViewControllers/ForwardMessageViewController.swift
2024-07-02 12:11:25 -05:00

935 lines
37 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
public protocol ForwardMessageDelegate: AnyObject {
func forwardMessageFlowDidComplete(items: [ForwardMessageItem],
recipientThreads: [TSThread])
func forwardMessageFlowDidCancel()
}
class ForwardMessageViewController: InteractiveSheetViewController {
private let pickerVC: ForwardPickerViewController
private let forwardNavigationViewController = ForwardNavigationViewController()
override var interactiveScrollViews: [UIScrollView] { [ pickerVC.tableView ] }
public weak var forwardMessageDelegate: ForwardMessageDelegate?
public typealias Item = ForwardMessageItem
fileprivate typealias Content = ForwardMessageContent
fileprivate var content: Content
fileprivate var textMessage: String?
private let selection = ConversationPickerSelection()
var selectedConversations: [ConversationItem] { selection.conversations }
override var sheetBackgroundColor: UIColor {
ForwardPickerViewController.tableBackgroundColor(isUsingPresentedStyle: true)
}
private init(content: Content) {
self.content = content
self.pickerVC = ForwardPickerViewController(selection: selection)
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
}
selectRecipientsStep()
minimizedHeight = 576
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public class func present(forItemViewModels itemViewModels: [CVItemViewModelImpl],
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate) {
do {
let content: Content = try Self.databaseStorage.read { transaction in
try Content.build(itemViewModels: itemViewModels, transaction: transaction)
}
present(content: content, from: fromViewController, delegate: delegate)
} catch {
ForwardMessageViewController.showAlertForForwardError(error: error,
forwardedInteractionCount: itemViewModels.count)
}
}
public class func present(forSelectionItems selectionItems: [CVSelectionItem],
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate) {
do {
let content: Content = try Self.databaseStorage.read { transaction in
try Content.build(selectionItems: selectionItems, transaction: transaction)
}
present(content: content, from: fromViewController, delegate: delegate)
} catch {
ForwardMessageViewController.showAlertForForwardError(error: error,
forwardedInteractionCount: selectionItems.count)
}
}
public class func present(
forAttachmentStreams attachmentStreams: [ReferencedTSResourceStream],
fromMessage message: TSMessage,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate
) {
do {
let builder = Item.Builder(interaction: message)
builder.attachments = try attachmentStreams.map { attachmentStream in
try DependenciesBridge.shared.tsResourceCloner.cloneAsSignalAttachment(
attachment: attachmentStream
)
}
let item: Item = builder.build()
present(
content: .single(item: item),
from: fromViewController,
delegate: delegate
)
} catch let error {
ForwardMessageViewController.showAlertForForwardError(
error: error,
forwardedInteractionCount: 1
)
}
}
public class func present(
forStoryMessage storyMessage: StoryMessage,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate
) {
let builder = Item.Builder()
switch storyMessage.attachment {
case .file, .foreignReferenceAttachment:
let attachment: ReferencedTSResourceStream? = databaseStorage.read { tx in
guard
let reference = DependenciesBridge.shared.tsResourceStore.mediaAttachment(for: storyMessage, tx: tx.asV2Read),
let attachmentStream = reference.fetch(tx: tx)?.asResourceStream()
else {
return nil
}
return .init(reference: reference, attachmentStream: attachmentStream)
}
do {
guard let attachment else {
throw OWSAssertionError("Missing attachment stream for forwarded story message")
}
let signalAttachment = try DependenciesBridge.shared.tsResourceCloner.cloneAsSignalAttachment(
attachment: attachment
)
builder.attachments = [signalAttachment]
} catch let error {
ForwardMessageViewController.showAlertForForwardError(
error: error,
forwardedInteractionCount: 1
)
return
}
case .text(let textAttachment):
builder.textAttachment = textAttachment
}
present(content: .single(item: builder.build()), from: fromViewController, delegate: delegate)
}
private class func present(content: Content,
from fromViewController: UIViewController,
delegate: ForwardMessageDelegate) {
let sheet = ForwardMessageViewController(content: content)
sheet.forwardMessageDelegate = delegate
fromViewController.present(sheet, animated: true) {
UIApplication.shared.hideKeyboard()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
ensureBottomFooterVisibility()
}
private func selectRecipientsStep() {
pickerVC.forwardMessageViewController = self
pickerVC.shouldShowSearchBar = false
pickerVC.shouldHideSearchBarIfCancelled = true
pickerVC.pickerDelegate = self
pickerVC.shouldBatchUpdateIdentityKeys = true
forwardNavigationViewController.forwardMessageViewController = self
forwardNavigationViewController.viewControllers = [ pickerVC ]
self.addChild(forwardNavigationViewController)
let navView = forwardNavigationViewController.view!
self.contentView.addSubview(navView)
navView.autoPinEdgesToSuperviewEdges()
}
fileprivate func selectSearchBar() {
AssertIsOnMainThread()
pickerVC.selectSearchBar()
ensureHeaderVisibility()
}
fileprivate func ensureHeaderVisibility() {
AssertIsOnMainThread()
forwardNavigationViewController.setNavigationBarHidden(pickerVC.isSearchBarActive, animated: false)
if pickerVC.isSearchBarActive {
maximizeHeight()
}
}
fileprivate 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
}
public override func willDismissInteractively() {
AssertIsOnMainThread()
forwardMessageDelegate?.forwardMessageFlowDidCancel()
}
}
// MARK: - Sending
extension ForwardMessageViewController {
private static let keyValueStore = SDSKeyValueStore(collection: "ForwardMessageViewController")
private static let hasForwardedKey = "hasForwardedKey"
private var hasForwardedWithSneakyTransaction: Bool {
databaseStorage.read { transaction in
Self.keyValueStore.getBool(Self.hasForwardedKey, defaultValue: false, transaction: transaction)
}
}
private static func markHasForwardedWithSneakyTransaction() {
databaseStorage.write { transaction in
Self.keyValueStore.setBool(true, key: Self.hasForwardedKey, transaction: transaction)
}
}
func sendStep() {
if hasForwardedWithSneakyTransaction {
tryToSend()
} else {
showFirstForwardAlert()
}
}
private func showFirstForwardAlert() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_TITLE",
comment: "Title for alert with information about forwarding messages."),
message: OWSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_MESSAGE",
comment: "Message for alert with information about forwarding messages.")
)
let format = OWSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_PROCEED_%d", tableName: "PluralAware",
comment: "Format for label for button to proceed with forwarding multiple messages. Embeds: {{ the number of forwarded messages. }}")
let actionTitle = String.localizedStringWithFormat(format, content.allItems.count)
actionSheet.addAction(ActionSheetAction(title: actionTitle) { [weak self] _ in
Self.markHasForwardedWithSneakyTransaction()
self?.tryToSend()
})
actionSheet.addAction(OWSActionSheets.cancelAction)
presentActionSheet(actionSheet)
}
private func tryToSend() {
AssertIsOnMainThread()
do {
try tryToSendThrows()
} catch {
owsFailDebug("Error: \(error)")
self.forwardMessageDelegate?.forwardMessageFlowDidCancel()
}
}
private func tryToSendThrows() throws {
let content = self.content
let textMessage = self.textMessage?.strippedOrNil
let recipientConversations = self.selectedConversations
firstly(on: DispatchQueue.global()) {
self.outgoingMessageRecipientThreads(for: recipientConversations)
}.then(on: DispatchQueue.main) { (outgoingMessageRecipientThreads: [TSThread]) -> Promise<Void> in
try Self.databaseStorage.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: SDSAnyReadTransaction) -> 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.
return firstly { () -> Promise<Void> in
// Maintain order of interactions.
let sortedItems = content.allItems.sorted { lhs, rhs in
lhs.interaction?.timestamp ?? 0 < rhs.interaction?.timestamp ?? 0
}
// _Enqueue_ each item serially.
// Each item waits on the previous' enqueue promise, then sets its own
// enqueue promise as the previous promise var.
var prevEnqueuePromise = Promise<Void>.value(())
for item in sortedItems {
prevEnqueuePromise = prevEnqueuePromise.then(on: DispatchQueue.main) {
return self.send(item: item, toOutgoingMessageRecipientThreads: outgoingMessageRecipientThreads)
}
}
return prevEnqueuePromise.then(on: DispatchQueue.main) { _ -> Promise<Void> in
// The user may have added an additional text message to the forward.
// It should be sent last.
if let textMessage = textMessage {
let messageBody = MessageBody(text: textMessage, ranges: .empty)
return self.send(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(body: messageBody, recipientThread: recipientThread)
}
} else {
return Promise.value(())
}
}
}.map(on: DispatchQueue.main) {
self.forwardMessageDelegate?.forwardMessageFlowDidComplete(
items: content.allItems,
recipientThreads: outgoingMessageRecipientThreads
)
}
}.catch(on: DispatchQueue.main) { error in
owsFailDebug("Error: \(error)")
Self.showAlertForForwardError(error: error, forwardedInteractionCount: content.allItems.count)
}
}
private func send(item: Item, toOutgoingMessageRecipientThreads outgoingMessageRecipientThreads: [TSThread]) -> Promise<Void> {
AssertIsOnMainThread()
if let stickerMetadata = item.stickerMetadata {
let stickerInfo = stickerMetadata.stickerInfo
if StickerManager.isStickerInstalled(stickerInfo: stickerInfo) {
return send(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(installedSticker: stickerInfo, thread: recipientThread)
}
} else {
guard let stickerAttachment = item.stickerAttachment else {
return Promise(error: OWSAssertionError("Missing stickerAttachment."))
}
do {
let stickerData = try stickerAttachment.decryptedRawData()
return send(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
self.send(uninstalledSticker: stickerMetadata,
stickerData: stickerData,
thread: recipientThread)
}
} catch {
return Promise(error: error)
}
}
} else if let contactShare = item.contactShare {
return send(toRecipientThreads: outgoingMessageRecipientThreads) { recipientThread in
return self.send(contactShare: contactShare.copyForResending(), thread: recipientThread)
}
} else if let attachments = item.attachments,
!attachments.isEmpty {
// TODO: What about link previews in this case?
let conversations = selectedConversations
return TSResourceMultisend.sendApprovedMedia(
conversations: conversations,
approvalMessageBody: item.messageBody,
approvedAttachments: attachments
).enqueuedPromise.asVoid()
} 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)
return TSResourceMultisend.sendTextAttachment(
textAttachment.asUnsentAttachment(), to: selectedConversations
).enqueuedPromise.asVoid()
} else if let messageBody = item.messageBody {
let linkPreviewDraft = item.linkPreviewDraft
let nonStorySendPromise = send(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.
let storyConversations = selectedConversations.filter { $0.outgoingMessageType == .storyMessage }
let storySendResult = StorySharing.sendTextStory(with: messageBody, linkPreviewDraft: linkPreviewDraft, to: storyConversations)
let storySendEnqueuedPromise = storySendResult?.enqueuedPromise.asVoid() ?? .value(())
return Promise<Void>.when(fulfilled: [nonStorySendPromise, storySendEnqueuedPromise])
} else {
return Promise(error: ForwardError.invalidInteraction)
}
}
fileprivate func send(body: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft? = nil, recipientThread: TSThread) -> Promise<Void> {
let body = databaseStorage.read { transaction in
return body.forForwarding(to: recipientThread, transaction: transaction.unwrapGrdbRead).asMessageBodyForForwarding()
}
ThreadUtil.enqueueMessage(
body: body,
thread: recipientThread,
linkPreviewDraft: linkPreviewDraft
)
return Promise.value(())
}
fileprivate func send(contactShare: ContactShareDraft, thread: TSThread) -> Promise<Void> {
ThreadUtil.enqueueMessage(withContactShare: contactShare, thread: thread)
return Promise.value(())
}
fileprivate func send(body: MessageBody, attachment: SignalAttachment, thread: TSThread) -> Promise<Void> {
let body = databaseStorage.read { transaction in
return body.forForwarding(to: thread, transaction: transaction.unwrapGrdbRead).asMessageBodyForForwarding()
}
ThreadUtil.enqueueMessage(body: body,
mediaAttachments: [attachment],
thread: thread)
return Promise.value(())
}
fileprivate func send(installedSticker stickerInfo: StickerInfo, thread: TSThread) -> Promise<Void> {
ThreadUtil.enqueueMessage(withInstalledSticker: stickerInfo, thread: thread)
return Promise.value(())
}
fileprivate func send(uninstalledSticker stickerMetadata: any StickerMetadata, stickerData: Data, thread: TSThread) -> Promise<Void> {
ThreadUtil.enqueueMessage(withUninstalledSticker: stickerMetadata, stickerData: stickerData, thread: thread)
return Promise.value(())
}
fileprivate func send(toRecipientThreads recipientThreads: [TSThread],
enqueueBlock: @escaping (TSThread) -> Promise<Void>) -> Promise<Void> {
AssertIsOnMainThread()
return Promise.when(fulfilled: recipientThreads.map { thread in enqueueBlock(thread) }).asVoid()
}
fileprivate func outgoingMessageRecipientThreads(for conversationItems: [ConversationItem]) -> Promise<[TSThread]> {
firstly(on: DispatchQueue.global()) {
guard conversationItems.count > 0 else {
throw OWSAssertionError("No recipients.")
}
return try self.databaseStorage.write { 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 {
false
}
func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController) {
forwardMessageDelegate?.forwardMessageFlowDidCancel()
}
func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode {
.send
}
func conversationPickerDidBeginEditingText() {
AssertIsOnMainThread()
maximizeHeight()
}
func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController) {
ensureHeaderVisibility()
}
}
// MARK: -
extension ForwardMessageViewController {
public static func finalizeForward(items: [Item],
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.")
}
fromViewController.presentToast(text: toast)
}
}
// MARK: -
public enum ForwardError: Error {
case missingInteraction
case missingThread
case invalidInteraction
}
// MARK: -
extension ForwardMessageViewController {
public 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: -
public struct ForwardMessageItem {
fileprivate typealias Item = ForwardMessageItem
let interaction: TSInteraction?
let attachments: [SignalAttachment]?
let contactShare: ContactShareViewModel?
let messageBody: MessageBody?
let linkPreviewDraft: OWSLinkPreviewDraft?
let stickerMetadata: (any StickerMetadata)?
let stickerAttachment: TSResourceStream?
let textAttachment: TextAttachment?
fileprivate class Builder {
let interaction: TSInteraction?
var attachments: [SignalAttachment]?
var contactShare: ContactShareViewModel?
var messageBody: MessageBody?
var linkPreviewDraft: OWSLinkPreviewDraft?
var stickerMetadata: (any StickerMetadata)?
var stickerAttachment: TSResourceStream?
var textAttachment: TextAttachment?
init(interaction: TSInteraction? = nil) {
self.interaction = interaction
}
func build() -> ForwardMessageItem {
ForwardMessageItem(
interaction: interaction,
attachments: attachments,
contactShare: contactShare,
messageBody: messageBody,
linkPreviewDraft: linkPreviewDraft,
stickerMetadata: stickerMetadata,
stickerAttachment: stickerAttachment,
textAttachment: textAttachment
)
}
}
fileprivate var asBuilder: Builder {
let builder = Builder(interaction: interaction)
builder.attachments = attachments
builder.contactShare = contactShare
builder.messageBody = messageBody
builder.linkPreviewDraft = linkPreviewDraft
builder.stickerMetadata = stickerMetadata
builder.stickerAttachment = stickerAttachment
return builder
}
var isEmpty: Bool {
if let attachments = attachments,
!attachments.isEmpty {
return false
}
if contactShare != nil ||
messageBody != nil ||
stickerMetadata != nil {
return false
}
return true
}
fileprivate static func build(
interaction: TSInteraction,
componentState: CVComponentState,
selectionType: CVSelectionType,
transaction: SDSAnyReadTransaction
) throws -> Item {
let builder = Builder(interaction: interaction)
let shouldHaveText = (selectionType == .allContent ||
selectionType == .secondaryContent)
let shouldHaveAttachments = (selectionType == .allContent ||
selectionType == .primaryContent)
guard shouldHaveText || shouldHaveAttachments else {
throw ForwardError.invalidInteraction
}
if shouldHaveText,
let displayableBodyText = componentState.displayableBodyText,
!displayableBodyText.fullTextValue.isEmpty {
switch displayableBodyText.fullTextValue {
case .text(let text):
builder.messageBody = MessageBody(text: text, ranges: .empty)
case .attributedText(let text):
builder.messageBody = MessageBody(text: text.string, ranges: .empty)
case .messageBody(let hydratedBody):
builder.messageBody = hydratedBody.asMessageBodyForForwarding(preservingAllMentions: true)
}
if let linkPreview = componentState.linkPreviewModel, let message = interaction as? TSMessage {
builder.linkPreviewDraft = Self.tryToCloneLinkPreview(
linkPreview: linkPreview,
parentMessage: message,
transaction: transaction
)
}
}
if shouldHaveAttachments {
if let oldContactShare = componentState.contactShareModel {
builder.contactShare = oldContactShare.copyForRendering()
}
var attachmentStreams = [ReferencedTSResourceStream]()
attachmentStreams.append(contentsOf: componentState.bodyMediaAttachmentStreams)
if let attachmentStream = componentState.audioAttachmentStream {
attachmentStreams.append(attachmentStream)
}
if let attachmentStream = componentState.genericAttachmentStream {
attachmentStreams.append(attachmentStream)
}
if !attachmentStreams.isEmpty {
builder.attachments = try attachmentStreams.map { attachmentStream in
try DependenciesBridge.shared.tsResourceCloner.cloneAsSignalAttachment(
attachment: attachmentStream
)
}
}
if let stickerMetadata = componentState.stickerMetadata {
builder.stickerMetadata = stickerMetadata
if let stickerAttachment = componentState.stickerAttachment {
builder.stickerAttachment = stickerAttachment
}
}
}
let item = builder.build()
guard !item.isEmpty else {
throw ForwardError.invalidInteraction
}
return item
}
private static func tryToCloneLinkPreview(
linkPreview: OWSLinkPreview,
parentMessage: TSMessage,
transaction: SDSAnyReadTransaction
) -> 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: TSResourceId,
transaction: SDSAnyReadTransaction
) -> LinkPreviewImage? {
guard
let attachment = DependenciesBridge.shared.tsResourceStore
.fetch(attachmentId, tx: transaction.asV2Read)?
.asResourceStream()
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 imageAttachmentId = DependenciesBridge.shared.tsResourceStore.linkPreviewAttachment(
for: parentMessage,
tx: transaction.asV2Read
)?.resourceId,
let image = LinkPreviewImage.load(
attachmentId: imageAttachmentId,
transaction: transaction
)
{
linkPreviewImage = image
}
let draft = OWSLinkPreviewDraft(url: url,
title: linkPreview.title,
imageData: linkPreviewImage?.imageData,
imageMimeType: linkPreviewImage?.mimetype)
draft.previewDescription = linkPreview.previewDescription
draft.date = linkPreview.date
return draft
}
}
// MARK: -
private enum ForwardMessageContent {
fileprivate typealias Item = ForwardMessageItem
case single(item: Item)
case multiple(items: [Item])
var allItems: [Item] {
switch self {
case .single(let item):
return [item]
case .multiple(let items):
return items
}
}
var canSendToStories: Bool {
allItems.allSatisfy { item in
if let attachments = item.attachments {
return attachments.allSatisfy({ $0.isValidImage || $0.isValidVideo })
} else if item.textAttachment != nil {
return true
} else if item.messageBody != nil {
return true
} else {
return false
}
}
}
var canSendToNonStories: Bool {
allItems.allSatisfy { $0.textAttachment == nil }
}
private static func build(items: [Item]) -> ForwardMessageContent {
if items.count == 1, let item = items.first {
return .single(item: item)
} else {
return .multiple(items: items)
}
}
fileprivate static func build(
itemViewModels: [CVItemViewModelImpl],
transaction: SDSAnyReadTransaction
) throws -> ForwardMessageContent {
let items: [Item] = try itemViewModels.map { itemViewModel in
try Item.build(interaction: itemViewModel.interaction,
componentState: itemViewModel.renderItem.componentState,
selectionType: .allContent,
transaction: transaction)
}
return build(items: items)
}
fileprivate static func build(
selectionItems: [CVSelectionItem],
transaction: SDSAnyReadTransaction
) throws -> ForwardMessageContent {
let items: [Item] = try selectionItems.map { selectionItem in
let interactionId = selectionItem.interactionId
guard let interaction = TSInteraction.anyFetch(uniqueId: interactionId,
transaction: transaction) else {
throw ForwardError.missingInteraction
}
let componentState = try buildComponentState(interaction: interaction,
transaction: transaction)
return try Item.build(interaction: interaction,
componentState: componentState,
selectionType: selectionItem.selectionType,
transaction: transaction)
}
return build(items: items)
}
private static func buildComponentState(interaction: TSInteraction,
transaction: SDSAnyReadTransaction) throws -> CVComponentState {
guard let componentState = CVLoader.buildStandaloneComponentState(
interaction: interaction,
spoilerState: SpoilerRenderState(), // Nothing revealed, doesn't matter.
transaction: transaction
) else {
throw ForwardError.invalidInteraction
}
return componentState
}
}
// MARK: -
private class ForwardNavigationViewController: OWSNavigationController {
weak var forwardMessageViewController: ForwardMessageViewController?
}
// MARK: -
private class ForwardPickerViewController: ConversationPickerViewController {
weak var forwardMessageViewController: ForwardMessageViewController?
public override func viewDidLoad() {
super.viewDidLoad()
updateNavigationItem()
}
public func updateNavigationItem() {
title = OWSLocalizedString("FORWARD_MESSAGE_TITLE",
comment: "Title for the 'forward message(s)' view.")
navigationItem.leftBarButtonItem = UIBarButtonItem(image: Theme.iconImage(.buttonX),
style: .plain,
target: self,
action: #selector(didPressCancel))
navigationItem.rightBarButtonItem = UIBarButtonItem(image: Theme.iconImage(.buttonSearch),
style: .plain,
target: self,
action: #selector(didPressSearch))
}
@objc
private func didPressCancel() {
AssertIsOnMainThread()
pickerDelegate?.conversationPickerDidCancel(self)
}
@objc
private func didPressSearch() {
AssertIsOnMainThread()
forwardMessageViewController?.selectSearchBar()
}
}