Signal-iOS/SignalUI/ViewControllers/TextApprovalViewController.swift
Igor Solomennikov 37e1a99209
Big update of link preview views.
LinkPreviewView was previously used to show link previews in chat and in Share Extension when text being shared contained an url. This commit renames LinkPreviewView to CVLinkPreviewView and simplifies the component to only handle sent links - support for showing intermediate "loading" state as well as having a cancel button to remove link preview - has been removed from this class.

OutgoingLinkPreviewView is used to show "link preview draft" in the chat input toolbar. This commit renames the class to LinkPreviewView and adopts this component to be used in Share Extension in place of the component mentioned above.

All those changes result in update link preview UI in Share Extension - visible when sharing a website from Safari or other mobile browser.
2026-02-26 21:12:36 -08:00

220 lines
7.2 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import LibSignalClient
public import SignalServiceKit
public protocol TextApprovalViewControllerDelegate: AnyObject {
func textApproval(_ textApproval: TextApprovalViewController, didApproveMessage messageBody: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?)
func textApprovalDidCancel(_ textApproval: TextApprovalViewController)
func textApprovalCustomTitle(_ textApproval: TextApprovalViewController) -> String?
func textApprovalRecipientsDescription(_ textApproval: TextApprovalViewController) -> String?
func textApprovalMode(_ textApproval: TextApprovalViewController) -> ApprovalMode
}
// MARK: -
public class TextApprovalViewController: OWSViewController, BodyRangesTextViewDelegate {
public weak var delegate: TextApprovalViewControllerDelegate?
// MARK: - Properties
private let initialMessageBody: MessageBody
private let linkPreviewFetchState: LinkPreviewFetchState
private let textView = BodyRangesTextView()
private let footerView = ApprovalFooterView()
private var approvalMode: ApprovalMode {
guard let delegate else {
return .send
}
return delegate.textApprovalMode(self)
}
// MARK: - Initializers
public init(messageBody: MessageBody) {
initialMessageBody = messageBody
linkPreviewFetchState = LinkPreviewFetchState(
db: DependenciesBridge.shared.db,
linkPreviewFetcher: SUIEnvironment.shared.linkPreviewFetcher,
linkPreviewSettingStore: DependenciesBridge.shared.linkPreviewSettingStore,
)
super.init()
linkPreviewFetchState.onStateChange = { [weak self] in self?.updateLinkPreviewView() }
}
// MARK: - View Lifecycle
override public func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .Signal.background
if let title = delegate?.textApprovalCustomTitle(self) {
navigationItem.title = title
} else {
navigationItem.title = OWSLocalizedString(
"MESSAGE_APPROVAL_DIALOG_TITLE",
comment: "Title for the 'message approval' dialog.",
)
}
navigationItem.leftBarButtonItem = .cancelButton { [weak self] in
guard let self else { return }
self.delegate?.textApprovalDidCancel(self)
}
let stackView = UIStackView(arrangedSubviews: [linkPreviewView, textView])
stackView.axis = .vertical
stackView.spacing = 12
view.addSubview(stackView)
view.addSubview(footerView)
stackView.translatesAutoresizingMaskIntoConstraints = false
footerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
footerView.topAnchor.constraint(equalTo: stackView.bottomAnchor),
footerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
footerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
footerView.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor),
])
textView.bodyRangesDelegate = self
textView.backgroundColor = .Signal.background
textView.textColor = .Signal.label
textView.font = UIFont.dynamicTypeBody
textView.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)
textView.contentInset = .zero
textView.textContainerInset = .zero
footerView.delegate = self
// Don't allow interactive dismissal.
isModalInPresentation = true
}
private func updateSendButton() {
guard
!textView.isEmpty,
let recipientsDescription = delegate?.textApprovalRecipientsDescription(self)
else {
footerView.isHidden = true
return
}
footerView.setNamesText(recipientsDescription, animated: false)
footerView.isHidden = false
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateSendButton()
updateLinkPreviewText()
textView.becomeFirstResponder()
}
// MARK: - Link Previews
private lazy var linkPreviewView: LinkPreviewView = {
let linkPreviewView = LinkPreviewView(state: .loading)
linkPreviewView.isHidden = true
linkPreviewView.cancelButton.addAction(
UIAction { [weak self] _ in
self?.didTapDeleteLinkPreview()
},
for: .primaryActionTriggered,
)
return linkPreviewView
}()
private func updateLinkPreviewText() {
linkPreviewFetchState.update(textView.messageBodyForSending)
}
private func updateLinkPreviewView() {
switch linkPreviewFetchState.currentState {
case .none, .failed:
linkPreviewView.isHidden = true
case .loading, .loaded:
linkPreviewView.configure(withState: linkPreviewFetchState.currentState)
linkPreviewView.isHidden = false
}
}
private func didTapDeleteLinkPreview() {
AssertIsOnMainThread()
linkPreviewFetchState.disable()
}
// MARK: - UITextViewDelegate
public func textViewDidChange(_ textView: UITextView) {
updateSendButton()
updateLinkPreviewText()
}
public func textViewDidBeginTypingMention(_ textView: BodyRangesTextView) {}
public func textViewDidEndTypingMention(_ textView: BodyRangesTextView) {}
public func textViewMentionPickerParentView(_ textView: BodyRangesTextView) -> UIView? {
nil
}
public func textViewMentionPickerReferenceView(_ textView: BodyRangesTextView) -> UIView? {
nil
}
public func textViewMentionPickerPossibleAcis(_ textView: BodyRangesTextView, tx: DBReadTransaction) -> [Aci] {
[]
}
public func textViewDisplayConfiguration(_ textView: BodyRangesTextView) -> HydratedMessageBody.DisplayConfiguration {
.composing(textViewColor: textView.textColor)
}
public func mentionPickerStyle(_ textView: BodyRangesTextView) -> MentionPickerStyle {
.default
}
// We want to invalidate the cache but reuse it within this same controller.
private let mentionCacheInvalidationKey = UUID().uuidString
public func textViewMentionCacheInvalidationKey(_ textView: BodyRangesTextView) -> String {
mentionCacheInvalidationKey
}
}
// MARK: -
extension TextApprovalViewController: ApprovalFooterDelegate {
public func approvalFooterDelegateDidRequestProceed(_ approvalFooterView: ApprovalFooterView) {
let linkPreviewDraft = linkPreviewFetchState.linkPreviewDraftIfLoaded
delegate?.textApproval(self, didApproveMessage: textView.messageBodyForSending, linkPreviewDraft: linkPreviewDraft)
}
public func approvalMode(_ approvalFooterView: ApprovalFooterView) -> ApprovalMode {
return approvalMode
}
public func approvalFooterDidBeginEditingText() {}
}