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

public import LibSignalClient
public import SignalServiceKit

public class CVTextLabel: NSObject {

    // MARK: -

    public struct MentionItem: Equatable {
        public let mentionAci: Aci
        public let range: NSRange

        public init(mentionAci: Aci, range: NSRange) {
            self.mentionAci = mentionAci
            self.range = range
        }
    }

    // MARK: -

    public struct ReferencedUserItem: Equatable {
        public let address: SignalServiceAddress
        public let range: NSRange

        public init(address: SignalServiceAddress, range: NSRange) {
            self.address = address
            self.range = range
        }
    }

    // MARK: -

    public struct UnrevealedSpoilerItem: Equatable {
        public let spoilerId: Int
        public let interactionUniqueId: String
        public let interactionIdentifier: InteractionSnapshotIdentifier
        public let range: NSRange

        public init(
            spoilerId: Int,
            interactionUniqueId: String,
            interactionIdentifier: InteractionSnapshotIdentifier,
            range: NSRange,
        ) {
            self.spoilerId = spoilerId
            self.interactionUniqueId = interactionUniqueId
            self.interactionIdentifier = interactionIdentifier
            self.range = range
        }
    }

    // MARK: -

    public struct DeleteAuthorItem: Equatable {
        public let deleteAuthorAci: Aci
        public let range: NSRange

        public init(deleteAuthorAci: Aci, range: NSRange) {
            self.deleteAuthorAci = deleteAuthorAci
            self.range = range
        }
    }

    // MARK: -

    public enum Item: Equatable, CustomStringConvertible {
        case dataItem(dataItem: TextCheckingDataItem)
        case mention(mentionItem: MentionItem)
        case referencedUser(referencedUserItem: ReferencedUserItem)
        case unrevealedSpoiler(UnrevealedSpoilerItem)
        case deleteAuthor(deleteAuthorItem: DeleteAuthorItem)

        public var range: NSRange {
            switch self {
            case .dataItem(let dataItem):
                return dataItem.range
            case .mention(let mentionItem):
                return mentionItem.range
            case .referencedUser(let referencedUserItem):
                return referencedUserItem.range
            case .unrevealedSpoiler(let item):
                return item.range
            case .deleteAuthor(let deleteAuthorItem):
                return deleteAuthorItem.range
            }
        }

        public var description: String {
            switch self {
            case .dataItem:
                return ".dataItem"
            case .mention:
                return ".mention"
            case .referencedUser:
                return ".referencedUser"
            case .unrevealedSpoiler:
                return ".unrevealedSpoiler"
            case .deleteAuthor:
                return ".deleteAuthor"
            }
        }
    }

    public enum LinkifyStyle {
        case linkAttribute
        case underlined(bodyTextColor: UIColor)
    }

    // MARK: -

    public struct Config {
        public let text: CVTextValue
        public let displayConfig: HydratedMessageBody.DisplayConfiguration
        public let font: UIFont
        public let textColor: UIColor
        public let selectionStyling: [NSAttributedString.Key: Any]
        public let textAlignment: NSTextAlignment
        public let lineBreakMode: NSLineBreakMode
        public let numberOfLines: Int
        public let cacheKey: String
        public let items: [Item]
        public let linkifyStyle: CVTextLabel.LinkifyStyle

        public init(
            text: CVTextValue,
            displayConfig: HydratedMessageBody.DisplayConfiguration,
            font: UIFont,
            textColor: UIColor,
            selectionStyling: [NSAttributedString.Key: Any],
            textAlignment: NSTextAlignment,
            lineBreakMode: NSLineBreakMode,
            numberOfLines: Int = 0,
            cacheKey: String? = nil,
            items: [Item],
            linkifyStyle: CVTextLabel.LinkifyStyle,
        ) {
            self.text = text
            self.displayConfig = displayConfig
            self.font = font
            self.textColor = textColor
            self.selectionStyling = selectionStyling
            self.textAlignment = textAlignment
            self.lineBreakMode = lineBreakMode
            self.numberOfLines = numberOfLines

            if let cacheKey {
                self.cacheKey = cacheKey
            } else {
                self.cacheKey = "\(text.cacheKey),\(displayConfig.sizingCacheKey),\(font.fontName),\(font.pointSize),\(numberOfLines),\(lineBreakMode.rawValue),\(textAlignment.rawValue)"
            }

            self.items = items
            self.linkifyStyle = linkifyStyle
        }
    }

    // MARK: -

    private let label = Label()

    public var view: UIView { label }

    override public init() {
        label.backgroundColor = .clear
        label.isOpaque = false

        super.init()
    }

    public func configureForRendering(config: Config, spoilerAnimationManager: SpoilerAnimationManager) {
        AssertIsOnMainThread()
        label.config = config
        label.spoilerAnimationManager = spoilerAnimationManager
        spoilerAnimationManager.prepareViewForRendering(view)
    }

    public func setIsCellVisible(_ isCellVisible: Bool) {
        label.setIsCellVisible(isCellVisible)
    }

    public func reset() {
        label.config = nil
        label.reset()
    }

    public class Measurement: CVMeasurementObject {
        public let size: CGSize
        public let lastLineRect: CGRect?

        init(size: CGSize, lastLineRect: CGRect?) {
            self.size = size
            self.lastLineRect = lastLineRect
        }

        static let empty = { Measurement(size: .zero, lastLineRect: nil) }()

        // MARK: - Equatable

        public static func ==(lhs: Measurement, rhs: Measurement) -> Bool {
            lhs.size == rhs.size && lhs.lastLineRect == rhs.lastLineRect
        }
    }

    public static func measureSize(config: Config, maxWidth: CGFloat) -> Measurement {
        guard config.text.isEmpty.negated else {
            return .empty
        }
        let attributedString = Label.formatAttributedString(config: config)

        let layoutManager = NSLayoutManager()
        let textContainer = NSTextContainer(size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude))

        layoutManager.addTextContainer(textContainer)
        textContainer.lineFragmentPadding = 0
        textContainer.lineBreakMode = config.lineBreakMode
        textContainer.maximumNumberOfLines = config.numberOfLines

        // The string must be assigned to the NSTextStorage *after* it has
        // an associated layout manager. Otherwise, the `NSOriginalFont`
        // attribute will not be defined correctly resulting in incorrect
        // measurement of character sets that font doesn't support natively
        // (CJK, Arabic, Emoji, etc.)
        let textStorage = NSTextStorage()
        textStorage.addLayoutManager(layoutManager)
        textStorage.setAttributedString(attributedString)

        // The NSTextStorage object owns all the other layout components,
        // so there are only weak references to it. In optimized builds,
        // this can result in it being freed before we perform measurement.
        // We can work around this by explicitly extending the lifetime of
        // textStorage until measurement is completed.
        return withExtendedLifetime(textStorage) {
            let glyphRange = layoutManager.glyphRange(for: textContainer)
            var lastLineRect: CGRect?
            if
                glyphRange.location != NSNotFound,
                glyphRange.length > 0
            {
                let lastGlyphIndex = glyphRange.length - 1
                lastLineRect = layoutManager.lineFragmentUsedRect(
                    forGlyphAt: lastGlyphIndex,
                    effectiveRange: nil,
                    withoutAdditionalLayout: true,
                )
            }

            let size = layoutManager.usedRect(for: textContainer).size.ceil
            return Measurement(size: size, lastLineRect: lastLineRect)
        }
    }

    // MARK: - Gestures

    public func itemForGesture(sender: UIGestureRecognizer) -> Item? {
        label.itemForGesture(sender: sender)
    }

    public func animate(selectedItem: Item) {
        label.animate(selectedItem: selectedItem)
    }

    // MARK: - Linkification

    public static func linkifyData(
        attributedText: NSMutableAttributedString,
        linkifyStyle: LinkifyStyle,
        items: [CVTextLabel.Item],
    ) {

        // Sort so that we can detect overlap.
        let items = items.sorted {
            $0.range.location < $1.range.location
        }

        for item in items {
            let range = item.range

            switch item {
            case .mention, .referencedUser, .unrevealedSpoiler, .deleteAuthor:
                // Do nothing; these are already styled.
                continue
            case .dataItem(let dataItem):
                guard let link = dataItem.url.absoluteString.nilIfEmpty else {
                    owsFailDebug("Could not build data link.")
                    continue
                }

                switch linkifyStyle {
                case .linkAttribute:
                    attributedText.addAttribute(.link, value: link, range: range)
                case .underlined(let bodyTextColor):
                    attributedText.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
                    attributedText.addAttribute(.underlineColor, value: bodyTextColor, range: range)
                }
            }
        }
    }

    // MARK: -

    fileprivate class Label: UIView {

        fileprivate var config: Config? {
            didSet {
                reset()
                apply(config: config)
            }
        }

        fileprivate var spoilerAnimationManager: SpoilerAnimationManager? {
            didSet {
                if spoilerAnimationManager == nil, let oldValue, self.isAnimatingSpoilers {
                    self.isAnimatingSpoilers = false
                    oldValue.removeViewAnimator(self)
                } else {
                    updateSpoilerAnimationState()
                }
            }
        }

        private lazy var textStorage = NSTextStorage()
        private lazy var layoutManager = NSLayoutManager()
        private lazy var textContainer = NSTextContainer()

        private var animationTimer: Timer?

        // MARK: -

        override init(frame: CGRect) {
            AssertIsOnMainThread()

            super.init(frame: frame)

            textStorage.addLayoutManager(layoutManager)
            layoutManager.addTextContainer(textContainer)

            isUserInteractionEnabled = true
            addInteraction(UIDragInteraction(delegate: self))
            contentMode = .redraw
        }

        @available(*, unavailable, message: "Unimplemented")
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }

        override var frame: CGRect {
            didSet {
                // Ensure the text container size is kept in sync;
                // this is used to compute spoiler positions.
                textContainer.size = bounds.size

                if oldValue != frame, isAnimatingSpoilers, let spoilerAnimationManager {
                    spoilerAnimationManager.didUpdateAnimationState(for: self)
                }
            }
        }

        private var isCellVisible = false

        fileprivate func setIsCellVisible(_ isCellVisible: Bool) {
            self.isCellVisible = isCellVisible
            updateSpoilerAnimationState()
        }

        fileprivate func reset() {
            AssertIsOnMainThread()

            animationTimer?.invalidate()
            animationTimer = nil
            updateSpoilerAnimationState()
        }

        private func apply(config: Config?) {
            AssertIsOnMainThread()

            guard let config else {
                reset()
                return
            }
            updateTextStorage(config: config)
        }

        override open func draw(_ rect: CGRect) {
            super.draw(rect)

            textContainer.size = bounds.size
            let glyphRange = layoutManager.glyphRange(for: textContainer)
            layoutManager.drawBackground(forGlyphRange: glyphRange, at: .zero)
            layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: .zero)
        }

        // MARK: -

        fileprivate func updateTextStorage(config: Config) {
            AssertIsOnMainThread()

            textContainer.lineFragmentPadding = 0
            textContainer.lineBreakMode = config.lineBreakMode
            textContainer.maximumNumberOfLines = config.numberOfLines
            textContainer.size = bounds.size

            guard config.text.isEmpty.negated else {
                reset()
                textStorage.setAttributedString(NSAttributedString())
                setNeedsDisplay()
                return
            }

            let attributedString = Self.formatAttributedString(config: config)
            textStorage.setAttributedString(attributedString)
            setNeedsDisplay()

            updateSpoilerAnimationState()
        }

        fileprivate static func formatAttributedString(config: Config) -> NSMutableAttributedString {
            let attributedString: NSMutableAttributedString
            switch config.text {
            case .text(let text):
                attributedString = NSMutableAttributedString(string: text)
                config.displayConfig.searchRanges?.apply(
                    attributedString,
                    isDarkThemeEnabled: Theme.isDarkThemeEnabled,
                )
            case .attributedText(let attributedText):
                attributedString = NSMutableAttributedString(attributedString: attributedText)
                config.displayConfig.searchRanges?.apply(
                    attributedString,
                    isDarkThemeEnabled: Theme.isDarkThemeEnabled,
                )
            case .messageBody(let messageBody):
                // This will internally apply search ranges, no need to handle separately.
                let attributedText = messageBody.asAttributedStringForDisplay(
                    config: config.displayConfig,
                    isDarkThemeEnabled: Theme.isDarkThemeEnabled,
                )
                attributedString = (attributedText as? NSMutableAttributedString) ?? NSMutableAttributedString(attributedString: attributedText)
            }

            // The original attributed string may not have an overall font assigned.
            // Without it, measurement will not be correct. We assign the default font
            // to any ranges that don't currently have a font assigned.
            attributedString.addDefaultAttributeToEntireString(.font, value: config.font)

            // Set a default text color based on the passed in config
            attributedString.addDefaultAttributeToEntireString(.foregroundColor, value: config.textColor)

            CVTextLabel.linkifyData(
                attributedText: attributedString,
                linkifyStyle: config.linkifyStyle,
                items: config.items,
            )

            var range = NSRange(location: 0, length: 0)
            var attributes = attributedString.attributes(at: 0, effectiveRange: &range)

            let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
            paragraphStyle.lineBreakMode = config.lineBreakMode
            paragraphStyle.alignment = config.textAlignment
            attributes[.paragraphStyle] = paragraphStyle
            attributedString.setAttributes(attributes, range: range)
            return attributedString
        }

        fileprivate func updateAttributesForSelection(selectedItem: Item? = nil) {
            AssertIsOnMainThread()

            guard let config else {
                reset()
                return
            }
            guard let selectedItem else {
                apply(config: config)
                return
            }

            switch selectedItem {
            case .mention, .referencedUser, .dataItem:
                textStorage.addAttributes(config.selectionStyling, range: selectedItem.range)
            case .unrevealedSpoiler:
                // Don't apply anything for spoilers.
                return
            case .deleteAuthor:
                // Don't apply anything for delete author
                return
            }

            setNeedsDisplay()
        }

        fileprivate func item(at location: CGPoint) -> Item? {
            AssertIsOnMainThread()

            guard let config = self.config else {
                return nil
            }
            guard textStorage.length > 0 else {
                return nil
            }

            guard
                let characterIndex = textContainer.characterIndex(
                    of: location,
                    textStorage: textStorage,
                    layoutManager: layoutManager,
                )
            else {
                return nil
            }

            for item in config.items {
                if item.range.contains(characterIndex) {
                    return item
                }
            }

            return nil
        }

        // MARK: - Animation

        func animate(selectedItem: Item) {
            AssertIsOnMainThread()

            updateAttributesForSelection(selectedItem: selectedItem)
            self.animationTimer?.invalidate()
            self.animationTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
                self?.updateAttributesForSelection()
            }
        }

        // MARK: Spoiler

        private var isAnimatingSpoilers = false

        private func updateSpoilerAnimationState() {
            let wantsToAnimate: Bool
            if isCellVisible, let config {
                switch config.text {
                case .text, .attributedText:
                    wantsToAnimate = false
                case .messageBody(let body):
                    wantsToAnimate = body.hasSpoilerRangesToAnimate
                }
            } else {
                wantsToAnimate = false
            }

            guard let spoilerAnimationManager else {
                return
            }
            guard isAnimatingSpoilers != wantsToAnimate else {
                if isAnimatingSpoilers {
                    spoilerAnimationManager.didUpdateAnimationState(for: self)
                }
                return
            }
            if wantsToAnimate {
                spoilerAnimationManager.addViewAnimator(self)
            } else {
                spoilerAnimationManager.removeViewAnimator(self)
            }
            self.isAnimatingSpoilers = wantsToAnimate
        }

        // MARK: - Gestures

        func itemForGesture(sender: UIGestureRecognizer) -> Item? {
            AssertIsOnMainThread()

            let location = sender.location(in: self)
            guard let selectedItem = item(at: location) else {
                return nil
            }

            return selectedItem
        }

        // MARK: -

        override func updateConstraints() {
            super.updateConstraints()

            deactivateAllConstraints()
        }
    }
}

// MARK: -

extension CVTextLabel.Label: SpoilerableViewAnimator {

    var spoilerableView: UIView? {
        return self
    }

    func spoilerFrames() -> [SpoilerFrame] {
        guard let config else { return [] }
        switch config.text {
        case .text, .attributedText:
            return []
        case .messageBody(let messageBody):
            return Self.spoilerFrames(
                messageBody: messageBody,
                displayConfig: config.displayConfig,
                textContainer: textContainer,
                textStorage: textStorage,
                layoutManager: layoutManager,
                bounds: self.bounds.size,
            )
        }
    }

    var spoilerFramesCacheKey: Int {
        var hasher = Hasher()
        hasher.combine("CVTextLabel.Label")
        hasher.combine(config?.text)
        config?.displayConfig.hashForSpoilerFrames(into: &hasher)
        // Order matters. 100x10 is not the same hash value as 10x100.
        hasher.combine(textContainer.size.width)
        hasher.combine(textContainer.size.height)
        return hasher.finalize()
    }

    // Every input here should be represented in the cache key above.
    private static func spoilerFrames(
        messageBody: HydratedMessageBody,
        displayConfig: HydratedMessageBody.DisplayConfiguration,
        textContainer: NSTextContainer,
        textStorage: NSTextStorage,
        layoutManager: NSLayoutManager,
        bounds: CGSize,
    ) -> [SpoilerFrame] {
        let spoilerRanges = messageBody.spoilerRangesForAnimation(config: displayConfig)
        return textContainer.boundingRects(
            ofCharacterRanges: spoilerRanges,
            rangeMap: \.range,
            textStorage: textStorage,
            layoutManager: layoutManager,
            transform: { rect, spoilerRange in
                return .init(
                    frame: rect,
                    color: spoilerRange.color,
                    style: spoilerRange.isSearchResult ? .highlight : .standard,
                )
            },
        )
    }
}

// MARK: -

extension CVTextLabel.Label: UIDragInteractionDelegate {
    public func dragInteraction(
        _ interaction: UIDragInteraction,
        itemsForBeginning session: UIDragSession,
    ) -> [UIDragItem] {
        guard nil != self.config else {
            owsFailDebug("Missing config.")
            return []
        }
        let location = session.location(in: self)
        guard let selectedItem = self.item(at: location) else {
            return []
        }

        switch selectedItem {
        case .mention:
            // We don't let users drag mentions yet.
            return []
        case .referencedUser:
            // Dragging is not applicable to referenced users
            return []
        case .unrevealedSpoiler:
            // Dragging is not applicable for spoilers.
            return []
        case .deleteAuthor:
            // Dragging is not applicable for admin delete author.
            return []
        case .dataItem(let dataItem):
            animate(selectedItem: selectedItem)

            let itemProvider = NSItemProvider(object: dataItem.snippet as NSString)
            let dragItem = UIDragItem(itemProvider: itemProvider)

            let glyphRange = self.layoutManager.glyphRange(
                forCharacterRange: selectedItem.range,
                actualCharacterRange: nil,
            )
            var textLineRects = [NSValue]()
            self.layoutManager.enumerateEnclosingRects(
                forGlyphRange: glyphRange,
                withinSelectedGlyphRange: NSRange(
                    location: NSNotFound,
                    length: 0,
                ),
                in: self.textContainer,
            ) { rect, _ in
                textLineRects.append(NSValue(cgRect: rect))
            }
            let previewParameters = UIDragPreviewParameters(textLineRects: textLineRects)
            let preview = UIDragPreview(view: self, parameters: previewParameters)
            dragItem.previewProvider = { preview }

            return [dragItem]
        }
    }
}