Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/LongTextViewController.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

protocol LongTextViewDelegate: AnyObject {
    func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController)
}

class LongTextViewController: OWSViewController {

    // MARK: - Properties

    weak var delegate: LongTextViewDelegate?

    private let itemViewModel: CVItemViewModelImpl
    private let threadViewModel: ThreadViewModel
    private let spoilerState: SpoilerRenderState

    private let textView: UITextView = {
        let textView = OWSTextView()
        textView.font = UIFont.dynamicTypeBody
        textView.textColor = .Signal.label
        textView.backgroundColor = .Signal.background
        textView.isOpaque = true
        textView.isEditable = false
        textView.isSelectable = true
        textView.isScrollEnabled = true
        textView.showsHorizontalScrollIndicator = false
        textView.showsVerticalScrollIndicator = true
        textView.isUserInteractionEnabled = true
        return textView
    }()

    private lazy var toolbar: UIToolbar = {
        let toolbar = UIToolbar()
        toolbar.items = [
            UIBarButtonItem(
                image: Theme.iconImage(.buttonShare),
                primaryAction: UIAction { [weak self] _ in
                    self?.shareButtonPressed()
                },
            ),

            .flexibleSpace(),

            UIBarButtonItem(
                image: Theme.iconImage(.buttonForward),
                primaryAction: UIAction { [weak self] _ in
                    self?.forwardButtonPressed()
                },
            ),
        ]
        if #unavailable(iOS 26) {
            toolbar.tintColor = Theme.primaryIconColor
            toolbar.setShadowImage(UIImage(), forToolbarPosition: .any)
        }
        return toolbar
    }()

    private var linkItems: [CVTextLabel.Item]?

    private var displayableText: DisplayableText? { itemViewModel.displayableBodyText }

    // MARK: - UIViewController

    init(
        itemViewModel: CVItemViewModelImpl,
        threadViewModel: ThreadViewModel,
        spoilerState: SpoilerRenderState,
    ) {
        self.itemViewModel = itemViewModel
        self.threadViewModel = threadViewModel
        self.spoilerState = spoilerState
        super.init()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.title = OWSLocalizedString(
            "LONG_TEXT_VIEW_TITLE",
            comment: "Title for the 'long text message' view.",
        )
        view.backgroundColor = .Signal.background

        view.addSubview(textView)
        view.addSubview(toolbar)
        textView.translatesAutoresizingMaskIntoConstraints = false
        toolbar.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            textView.topAnchor.constraint(equalTo: view.topAnchor),
            textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
        ])

        if #available(iOS 26, *) {
            let interaction = UIScrollEdgeElementContainerInteraction()
            interaction.edge = .bottom
            interaction.scrollView = textView
            toolbar.addInteraction(interaction)
        }

        loadContent()

        if #available(iOS 17, *) {
            // Modern alternative to `themeDidChange`.
            textView.registerForTraitChanges([UITraitUserInterfaceStyle.self, UITraitPreferredContentSizeCategory.self]) { [weak self] (view: UIView, _) in
                self?.loadContent()
            }
        }

        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // Scroll to top.
        textView.contentOffset = CGPoint(x: 0, y: textView.contentInset.top)
    }

    override func viewLayoutMarginsDidChange() {
        super.viewLayoutMarginsDidChange()

        // Text view is constrained to root view's left and right safe areas.
        // Readable content guide's left and right margins include respective safe areas.
        // We need to subtract safe area margins from readable content guide's margins to get proper text alignment.
        // Since we use view's and layout guide's frames here make sure to only operate
        // with "left" and "right" insets and don't mix with "leading" / "trailing".
        textView.textContainerInset.left = view.readableContentGuide.layoutFrame.minX - view.safeAreaInsets.left
        textView.textContainerInset.right = view.bounds.maxX - view.readableContentGuide.layoutFrame.maxX - view.safeAreaInsets.left
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // UIKit adjusts bottom inset for the safe area height, so we just need to account for toolbar height.
        let bottomInset = toolbar.frame.height
        textView.textContainerInset.bottom = bottomInset + 16
        textView.verticalScrollIndicatorInsets.bottom = bottomInset
    }

    override func themeDidChange() {
        super.themeDidChange()

        if #unavailable(iOS 26) {
            toolbar.tintColor = Theme.primaryIconColor
        }
        if #unavailable(iOS 17) {
            loadContent()
        }
    }

    override func contentSizeCategoryDidChange() {
        super.contentSizeCategoryDidChange()
        if #unavailable(iOS 17) {
            loadContent()
        }
    }

    // MARK: - Content

    private func loadContent() {
        let displayConfig = HydratedMessageBody.DisplayConfiguration.longMessageView(
            revealedSpoilerIds: spoilerState.revealState.revealedSpoilerIds(
                interactionIdentifier: .fromInteraction(itemViewModel.interaction),
            ),
        )

        messageTextViewSpoilerConfig.animationManager = spoilerState.animationManager
        messageTextViewSpoilerConfig.text = displayableText?.fullTextValue
        messageTextViewSpoilerConfig.displayConfig = displayConfig

        guard let displayableText else {
            owsFailDebug("displayableText was unexpectedly nil")
            textView.text = ""
            return
        }

        let textColor: UIColor
        if #available(iOS 17, *) {
            textColor = UIColor.Signal.label.resolvedColor(with: textView.traitCollection)
        } else {
            textColor = Theme.primaryTextColor
        }

        let baseAttrs: [NSAttributedString.Key: Any] = [
            .font: UIFont.dynamicTypeBody,
            .foregroundColor: textColor,
        ]

        let mutableText: NSMutableAttributedString
        switch displayableText.fullTextValue {
        case .text(let text):
            mutableText = NSMutableAttributedString(string: text, attributes: baseAttrs)
        case .attributedText(let text):
            mutableText = NSMutableAttributedString(attributedString: text)
            mutableText.addAttributesToEntireString(baseAttrs)
        case .messageBody(let messageBody):
            let attrString = messageBody.asAttributedStringForDisplay(
                config: displayConfig,
                isDarkThemeEnabled: Theme.isDarkThemeEnabled,
            )
            mutableText = (attrString as? NSMutableAttributedString) ?? NSMutableAttributedString(attributedString: attrString)
        }

        let hasPendingMessageRequest = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            itemViewModel.thread.hasPendingMessageRequest(transaction: transaction)
        }
        CVComponentBodyText.configureTextView(
            textView,
            interaction: itemViewModel.interaction,
            displayableText: displayableText,
        )

        let items = CVComponentBodyText.detectItems(
            text: displayableText,
            hasPendingMessageRequest: hasPendingMessageRequest,
            shouldAllowLinkification: displayableText.shouldAllowLinkification,
            textWasTruncated: false,
            revealedSpoilerIds: displayConfig.style.revealedIds,
            interactionUniqueId: itemViewModel.interaction.uniqueId,
            interactionIdentifier: .fromInteraction(itemViewModel.interaction),
        )

        CVTextLabel.linkifyData(
            attributedText: mutableText,
            linkifyStyle: .linkAttribute,
            items: items,
        )
        textView.attributedText = mutableText
        textView.textAlignment = displayableText.fullTextNaturalAlignment
        linkItems = items

        if items.isEmpty.negated {
            textView.addGestureRecognizer(UITapGestureRecognizer(
                target: self,
                action: #selector(didTapMessageTextView),
            ))
        }

        textView.linkTextAttributes = [
            .foregroundColor: textColor,
            .underlineColor: textColor,
            .underlineStyle: NSUnderlineStyle.single.rawValue,
        ]
    }

    private func checkIfMessageWasDeleted() {
        AssertIsOnMainThread()

        let uniqueId = itemViewModel.interaction.uniqueId
        let messageWasDeleted = SSKEnvironment.shared.databaseStorageRef.read {
            TSInteraction.fetchViaCache(uniqueId: uniqueId, transaction: $0) == nil
        }
        guard messageWasDeleted else { return }

        Logger.error("Message was deleted")
        DispatchQueue.main.async {
            self.delegate?.longTextViewMessageWasDeleted(self)
        }
    }

    // MARK: - Spoiler Animation

    private lazy var messageTextViewSpoilerConfig = SpoilerableTextConfig.Builder(isViewVisible: true) {
        didSet {
            messageTextViewSpoilerAnimator.updateAnimationState(messageTextViewSpoilerConfig)
        }
    }

    private lazy var messageTextViewSpoilerAnimator: SpoilerableTextViewAnimator = {
        let animator = SpoilerableTextViewAnimator(textView: textView)
        animator.updateAnimationState(messageTextViewSpoilerConfig)
        return animator
    }()

    // MARK: - Actions

    private func shareButtonPressed() {
        guard let displayableText else { return }

        let shareText: String
        switch displayableText.fullTextValue {
        case .text(let text):
            shareText = text
        case .attributedText(let string):
            shareText = string.string
        case .messageBody(let messageBody):
            shareText = messageBody.asPlaintext()
        }
        AttachmentSharing.showShareUI(for: shareText, sender: toolbar.items?.first)
    }

    private func forwardButtonPressed() {
        // Only forward text.
        let selectionType: CVSelectionType = (
            itemViewModel.componentState.hasPrimaryAndSecondaryContentForSelection
                ? .secondaryContent
                : .allContent,
        )
        let selectionItem = CVSelectionItem(
            interactionId: itemViewModel.interaction.uniqueId,
            interactionType: itemViewModel.interaction.interactionType,
            isForwardable: true,
            selectionType: selectionType,
        )
        ForwardMessageViewController.present(
            forSelectionItems: [selectionItem],
            from: self,
            delegate: self,
        )
    }

    @objc
    private func didTapMessageTextView(_ sender: UIGestureRecognizer) {
        guard let linkItems else {
            return
        }
        let location = sender.location(in: textView)

        guard let characterIndex = textView.characterIndex(of: location) else {
            return
        }

        for item in linkItems {
            if item.range.contains(characterIndex) {
                switch item {
                case .referencedUser:
                    owsFailDebug("Should not have referenced user in long message body.")
                    return
                case .dataItem(let dataItem):
                    UIApplication.shared.open(dataItem.url, options: [:], completionHandler: nil)
                    return
                case .mention(let mentionItem):
                    ImpactHapticFeedback.impactOccurred(style: .light)

                    var groupViewHelper: GroupViewHelper?
                    if threadViewModel.isGroupThread {
                        groupViewHelper = GroupViewHelper(threadViewModel: threadViewModel, memberLabelCoordinator: nil)
                        groupViewHelper!.delegate = self
                    }

                    let address = SignalServiceAddress(mentionItem.mentionAci)
                    ProfileSheetSheetCoordinator(
                        address: address,
                        groupViewHelper: groupViewHelper,
                        spoilerState: spoilerState,
                    )
                    .presentAppropriateSheet(from: self)
                    return
                case .unrevealedSpoiler(let unrevealedSpoiler):
                    self.spoilerState.revealState.setSpoilerRevealed(
                        withID: unrevealedSpoiler.spoilerId,
                        interactionIdentifier: unrevealedSpoiler.interactionIdentifier,
                    )
                    self.loadContent()
                    return
                case .deleteAuthor:
                    owsFailDebug("delete author should not appear in long message body")
                }
            }
        }
    }
}

// MARK: -

extension LongTextViewController: DatabaseChangeDelegate {

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard databaseChanges.didUpdate(interaction: itemViewModel.interaction) else {
            return
        }
        assert(databaseChanges.didUpdateInteractions)

        checkIfMessageWasDeleted()
    }

    func databaseChangesDidUpdateExternally() {
        checkIfMessageWasDeleted()
    }

    func databaseChangesDidReset() {
        checkIfMessageWasDeleted()
    }
}

// MARK: -

extension LongTextViewController: ForwardMessageDelegate {
    func forwardMessageFlowDidComplete(
        items: [ForwardMessageItem],
        recipientThreads: [TSThread],
    ) {
        dismiss(animated: true) {
            ForwardMessageViewController.finalizeForward(
                items: items,
                recipientThreads: recipientThreads,
                fromViewController: self,
            )
        }
    }

    func forwardMessageFlowDidCancel() {
        dismiss(animated: true)
    }
}

// MARK: -

extension LongTextViewController: GroupViewHelperDelegate {
    var currentGroupModel: TSGroupModel? {
        return (threadViewModel.threadRecord as? TSGroupThread)?.groupModel
    }

    func groupViewHelperDidUpdateGroup() {}

    var fromViewController: UIViewController? {
        return self
    }
}