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

import SignalServiceKit
import SignalUI

class MediaCaptionView: UIView {

    private let spoilerState: SpoilerRenderState

    enum Content: Equatable {
        case attachmentStreamCaption(String)
        case messageBody(HydratedMessageBody, InteractionSnapshotIdentifier)

        var nilIfEmpty: Content? {
            switch self {
            case .attachmentStreamCaption(let string):
                return string.isEmpty ? nil : self
            case .messageBody(let messageBody, let identifier):
                return messageBody.nilIfEmpty.map { .messageBody($0, identifier) }
            }
        }

        var isEmpty: Bool {
            switch self {
            case .attachmentStreamCaption(let string):
                return string.isEmpty
            case .messageBody(let messageBody, _):
                return messageBody.isEmpty
            }
        }

        var interactionIdentifier: InteractionSnapshotIdentifier? {
            switch self {
            case .attachmentStreamCaption:
                return nil
            case .messageBody(_, let id):
                return id
            }
        }
    }

    var content: Content? {
        get {
            return captionTextView.content
        }
        set {
            let oldValue = captionTextView.content
            guard oldValue != newValue else {
                return
            }
            captionTextView.content = newValue
        }
    }

    var isEmpty: Bool {
        guard let content else { return true }
        return content.isEmpty
    }

    var canBeExpanded: Bool {
        captionTextView.canBeExpanded
    }

    var isExpanded: Bool {
        get { captionTextView.isExpanded }
        set { captionTextView.isExpanded = newValue }
    }

    // MARK: Initializers

    init(
        frame: CGRect = .zero,
        spoilerState: SpoilerRenderState,
    ) {
        self.spoilerState = spoilerState
        super.init(frame: frame)

        clipsToBounds = true

        let selfOrVisualEffectContentView: UIView
        if #available(iOS 26, *) {
            let glassEffectView = UIVisualEffectView(effect: glassEffect())
            glassEffectView.translatesAutoresizingMaskIntoConstraints = false
            glassEffectView.clipsToBounds = true
            glassEffectView.cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: 26))
            addSubview(glassEffectView)
            NSLayoutConstraint.activate([
                glassEffectView.topAnchor.constraint(equalTo: topAnchor),
                glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
                glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
                glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])

            selfOrVisualEffectContentView = glassEffectView.contentView
            glassBackgroundView = glassEffectView

            directionalLayoutMargins = .init(margin: 16)
            insetsLayoutMarginsFromSafeArea = false
        } else {
            selfOrVisualEffectContentView = self

            directionalLayoutMargins = .init(hMargin: 0, vMargin: 4)
        }

        selfOrVisualEffectContentView.addSubview(captionTextView)
        captionTextView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            captionTextView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            captionTextView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            captionTextView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
            captionTextView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
        ])
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func handleTap(_ gestureRecognizer: UITapGestureRecognizer) -> Bool {
        let messageBody: HydratedMessageBody
        let interactionIdentifier: InteractionSnapshotIdentifier
        switch content {
        case .none, .attachmentStreamCaption:
            return false
        case .messageBody(let body, let id):
            messageBody = body
            interactionIdentifier = id
        }

        let location = gestureRecognizer.location(in: captionTextView)
        guard let characterIndex = captionTextView.characterIndex(of: location) else {
            return false
        }

        for item in messageBody.tappableItems(
            revealedSpoilerIds: spoilerState.revealState.revealedSpoilerIds(interactionIdentifier: interactionIdentifier),
            dataDetector: nil, /* Maybe in the future we should detect links here. We never have, before. */
        ) {
            switch item {
            case .data, .mention:
                continue
            case .unrevealedSpoiler(let unrevealedSpoiler):
                if unrevealedSpoiler.range.contains(characterIndex) {
                    spoilerState.revealState.setSpoilerRevealed(
                        withID: unrevealedSpoiler.id,
                        interactionIdentifier: interactionIdentifier,
                    )
                    didUpdateRevealedSpoilers(spoilerState.revealState)
                    return true
                }
            }
        }
        return false
    }

    func didUpdateRevealedSpoilers(_ spoilerReveal: SpoilerRevealState) {
        captionTextView.didUpdateRevealedSpoilers()
    }

    // MARK: Glass background

    private var _hasGlassBackground: Bool = true

    @available(iOS 26, *)
    var hasGlassBackground: Bool {
        get { _hasGlassBackground }
        set {
            _hasGlassBackground = newValue
            updateBackground()
        }
    }

    // Glass on iOS 26, `nil` otherwise.
    private var glassBackgroundView: UIVisualEffectView?

    @available(iOS 26, *)
    private func glassEffect() -> UIVisualEffect? {
        let glassEffect = UIGlassEffect(style: .regular)
        glassEffect.isInteractive = true
        return glassEffect
    }

    @available(iOS 26, *)
    private func updateBackground() {
        if hasGlassBackground {
            if let glassBackgroundView, glassBackgroundView.effect == nil {
                glassBackgroundView.effect = glassEffect()
            }
        } else {
            if let glassBackgroundView {
                glassBackgroundView.effect = nil
            }
        }
    }

    // MARK: Animations

    func prepareToBeAnimatedIn() {
        if #available(iOS 26, *), let glassBackgroundView, hasGlassBackground {
            glassBackgroundView.effect = nil
        }
        captionTextView.alpha = 0
        isHidden = false
    }

    func animateIn() {
        if #available(iOS 26, *), let glassBackgroundView, hasGlassBackground {
            glassBackgroundView.effect = glassEffect()
        }
        captionTextView.alpha = 1
    }

    func animateOut() {
        if #available(iOS 26, *), let glassBackgroundView, hasGlassBackground {
            glassBackgroundView.effect = nil
        }
        captionTextView.alpha = 0
    }

    // MARK: Subviews

    private class func buildCaptionTextView(spoilerState: SpoilerRenderState) -> CaptionTextView {
        let textView = CaptionTextView(spoilerState: spoilerState)
        let config = HydratedMessageBody.DisplayConfiguration.mediaCaption(
            textColor: .Signal.label,
            revealedSpoilerIds: Set(),
        )
        textView.font = config.baseFont
        textView.textColor = config.baseTextColor.forCurrentTheme
        textView.backgroundColor = .clear
        textView.textContainerInset = .zero
        return textView
    }

    private lazy var captionTextView = MediaCaptionView.buildCaptionTextView(spoilerState: spoilerState)

    private class CaptionTextView: UITextView, NSLayoutManagerDelegate {

        init(spoilerState: SpoilerRenderState) {
            var spoilerConfig = SpoilerableTextConfig.Builder(isViewVisible: true)
            spoilerConfig.animationManager = spoilerState.animationManager
            self.spoilerConfig = spoilerConfig
            self.spoilerState = spoilerState

            super.init(frame: .zero, textContainer: nil)

            isEditable = false
            isSelectable = false
            textContainer.lineBreakMode = .byTruncatingTail
            textColor = .Signal.label

            disableAiWritingTools()
            updateIsScrollEnabled()
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        private let spoilerState: SpoilerRenderState

        private var spoilerConfig: SpoilerableTextConfig.Builder {
            didSet {
                spoilerAnimator.updateAnimationState(spoilerConfig)
            }
        }

        private lazy var spoilerAnimator = SpoilerableTextViewAnimator(textView: self)

        var content: MediaCaptionView.Content? {
            didSet {
                recomputeContents()
                invalidateCachedSizes()
            }
        }

        func didUpdateRevealedSpoilers() {
            // No need to recompute cached sizes; spoilers have no effect on size.
            recomputeContents()
        }

        @discardableResult
        private func recomputeContents(doUpdate: Bool = true) -> NSAttributedString? {
            switch content?.nilIfEmpty {
            case .none:
                if doUpdate {
                    super.attributedText = nil
                    spoilerConfig.text = nil
                    spoilerConfig.displayConfig = nil
                }
                return nil
            case .attachmentStreamCaption(let string):
                let attrString = NSAttributedString(string: string)
                if doUpdate {
                    super.attributedText = attrString
                    spoilerConfig.text = nil
                    spoilerConfig.displayConfig = nil
                }
                return attrString
            case .messageBody(let body, let interactionIdentifier):
                let displayConfig = HydratedMessageBody.DisplayConfiguration.mediaCaption(
                    textColor: textColor ?? .Signal.label,
                    revealedSpoilerIds: spoilerState.revealState.revealedSpoilerIds(
                        interactionIdentifier: interactionIdentifier,
                    ),
                )
                let attrString = body.asAttributedStringForDisplay(
                    config: displayConfig,
                    isDarkThemeEnabled: Theme.isDarkThemeEnabled,
                )
                if doUpdate {
                    super.attributedText = attrString
                    spoilerConfig.text = .messageBody(body)
                    spoilerConfig.displayConfig = displayConfig
                }
                return attrString
            }
        }

        @available(*, unavailable)
        override var attributedText: NSAttributedString! {
            didSet {}
        }

        @available(*, unavailable)
        override var text: String! {
            didSet {}
        }

        override var font: UIFont? {
            didSet {
                invalidateCachedSizes()
            }
        }

        override var bounds: CGRect {
            didSet {
                if oldValue.width != bounds.width {
                    invalidateCachedSizes()
                }
            }
        }

        override var frame: CGRect {
            didSet {
                if oldValue.width != bounds.width {
                    invalidateCachedSizes()
                }
            }
        }

        // MARK: -

        var canBeExpanded: Bool {
            collapsedSize.height != expandedSize.height
        }

        private var _isExpanded: Bool = false
        var isExpanded: Bool {
            get {
                guard canBeExpanded else { return false }
                return _isExpanded
            }
            set {
                guard _isExpanded != newValue else { return }
                _isExpanded = canBeExpanded ? newValue : false
                invalidateIntrinsicContentSize()
                updateIsScrollEnabled()
            }
        }

        private func updateIsScrollEnabled() {
            isScrollEnabled = isExpanded
        }

        // MARK: Layout metrics

        private static let maxHeight = CGFloat.scaleFromIPhone5(200)
        private static let collapsedNumberOfLines = 3

        private var collapsedSize: CGSize = .zero // 3 lines of text
        private var expandedSize: CGSize = .zero // height is limited to `maxHeight`
        private var fullSize: CGSize = .zero

        override var intrinsicContentSize: CGSize {
            guard content?.nilIfEmpty != nil else {
                return CGSize(width: UIView.noIntrinsicMetric, height: 0)
            }

            calculateSizesIfNecessary()

            let textSize = isExpanded ? expandedSize : collapsedSize
            return CGSize(
                width: textContainerInset.left + textSize.width + textContainerInset.right,
                height: textContainerInset.top + textSize.height + textContainerInset.bottom,
            )
        }

        private func invalidateCachedSizes() {
            collapsedSize = .zero
            expandedSize = .zero
            fullSize = .zero

            invalidateIntrinsicContentSize()
        }

        private func calculateSizesIfNecessary() {
            guard !collapsedSize.isNonEmpty else { return }
            guard let attributedText = recomputeContents(doUpdate: false) else { return }

            let maxWidth: CGFloat
            if frame.width > 0 {
                maxWidth = frame.width - textContainerInset.left - textContainerInset.right
            } else {
                maxWidth = .greatestFiniteMagnitude
            }

            // 3 lines of text.
            let font = font ?? .dynamicTypeBodyClamped
            let textColor = textColor ?? .Signal.label
            let collapsedTextConfig = CVTextLabel.Config(
                text: .attributedText(attributedText),
                displayConfig: .forUnstyledText(font: font, textColor: textColor),
                font: font,
                textColor: textColor,
                selectionStyling: [:],
                textAlignment: textAlignment,
                lineBreakMode: .byWordWrapping,
                numberOfLines: Self.collapsedNumberOfLines,
                items: [],
                linkifyStyle: .underlined(bodyTextColor: textColor),
            )
            collapsedSize = CVTextLabel.measureSize(config: collapsedTextConfig, maxWidth: maxWidth).size

            // 9 lines of text or `maxHeight`, whichever is smaller.
            let expandedTextConfig = CVTextLabel.Config(
                text: .attributedText(attributedText),
                displayConfig: .forUnstyledText(font: font, textColor: textColor),
                font: font,
                textColor: textColor,
                selectionStyling: [:],
                textAlignment: textAlignment,
                lineBreakMode: .byWordWrapping,
                numberOfLines: 3 * Self.collapsedNumberOfLines,
                items: [],
                linkifyStyle: .underlined(bodyTextColor: textColor),
            )
            let expandedTextSize = CVTextLabel.measureSize(config: expandedTextConfig, maxWidth: maxWidth).size
            expandedSize = CGSize(width: expandedTextSize.width, height: min(expandedTextSize.height, Self.maxHeight))

            // Unrestricted text height is necessary so that we could enable scrolling in the text view.
            let fullTextConfig = CVTextLabel.Config(
                text: .attributedText(attributedText),
                displayConfig: .forUnstyledText(font: font, textColor: textColor),
                font: font,
                textColor: textColor,
                selectionStyling: [:],
                textAlignment: textAlignment,
                lineBreakMode: .byWordWrapping,
                numberOfLines: 0,
                items: [],
                linkifyStyle: .underlined(bodyTextColor: textColor),
            )
            fullSize = CVTextLabel.measureSize(config: fullTextConfig, maxWidth: maxWidth).size
        }
    }
}