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

import SignalServiceKit

/**
 * Given an attributed string and a highlightRange, draws a colored capsule behind the characters in highlightRange.
 * The color of the capsule is determined by the textColor with opacity decreased.
 * highlightFont allows for the capsule text to be a different font (e.g. bold or not bold) from the rest of the attributed text.
 * Since we don't want the highlight range to wrap, but we may want the rest of the range to wrap, this class manually
 * truncates text longer than the given width and adds an ellipsis.
 */
public class CVCapsuleLabel: UILabel {
    public enum PresentationContext {
        case nonMessageBubble
        case messageBubbleRegular
        case messageBubbleQuoteReplyIncoming
        case messageBubbleQuoteReplyOutgoing
        case nameNotVerifiedWarning
    }

    public let highlightRange: NSRange
    public let highlightFont: UIFont
    public let axLabelPrefix: String?
    public let presentationContext: PresentationContext
    public let onTap: (() -> Void)?

    // *CapsuleInset is how far beyond the text the capsule expands.
    // *Offset is how shifted BOTH capsule & text are from the edge of the view.
    private static let horizontalCapsuleInset: CGFloat = 8
    private static let verticalCapsuleInset: CGFloat = 1
    private static let verticalOffset: CGFloat = 3
    private static let horizontalOffset: CGFloat = 8

    public init(
        attributedText: NSAttributedString,
        textColor: UIColor,
        font: UIFont?,
        highlightRange: NSRange,
        highlightFont: UIFont,
        axLabelPrefix: String?,
        presentationContext: PresentationContext,
        lineBreakMode: NSLineBreakMode = .byTruncatingTail,
        numberOfLines: Int = 0,
        signalSymbolRange: NSRange?,
        onTap: (() -> Void)?,
    ) {
        self.highlightRange = highlightRange
        self.highlightFont = highlightFont
        self.axLabelPrefix = axLabelPrefix
        self.presentationContext = presentationContext
        self.onTap = onTap

        super.init(frame: .zero)

        self.font = font
        self.textColor = textColor
        self.lineBreakMode = lineBreakMode
        self.numberOfLines = numberOfLines

        isUserInteractionEnabled = true
        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapMemberLabel)))

        let attributedString = NSMutableAttributedString(attributedString: attributedText)
        applyFontToAttributedString(attributedString, signalSymbolRange: signalSymbolRange)
        attributedString.addAttribute(.foregroundColor, value: textColor, range: attributedText.entireRange)

        self.attributedText = attributedString
    }

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

    private var capsuleColor: UIColor {
        switch presentationContext {
        case .messageBubbleQuoteReplyOutgoing:
            return UIColor.white.withAlphaComponent(0.36)
        case .messageBubbleQuoteReplyIncoming:
            if Theme.isDarkThemeEnabled {
                return UIColor.white.withAlphaComponent(0.16)
            }
            return UIColor.black.withAlphaComponent(0.1)
        case .messageBubbleRegular, .nonMessageBubble:
            if Theme.isDarkThemeEnabled {
                return textColor.withAlphaComponent(0.32)
            }
            return textColor.withAlphaComponent(0.14)
        case .nameNotVerifiedWarning:
            if Theme.isDarkThemeEnabled {
                return textColor.withAlphaComponent(0.2)
            }
            return textColor.withAlphaComponent(0.12)
        }
    }

    private func applyFontToAttributedString(_ attributedString: NSMutableAttributedString, signalSymbolRange: NSRange?) {
        guard let signalSymbolRange else {
            attributedString.addAttribute(.font, value: self.font!, range: attributedString.entireRange)
            // The highlighted text may have different font than the sender name
            attributedString.addAttribute(.font, value: highlightFont, range: highlightRange)
            return
        }

        // Apply font avoiding the signal symbol
        attributedString.applyAttributesToRangeAvoidingSubrange(
            attributes: [.font: self.font!],
            range: attributedString.entireRange,
            subrangeToAvoid: signalSymbolRange,
        )
        attributedString.applyAttributesToRangeAvoidingSubrange(
            attributes: [.font: highlightFont],
            range: highlightRange,
            subrangeToAvoid: signalSymbolRange,
        )
    }

    @objc
    func didTapMemberLabel() {
        onTap?()
    }

    /// Takes an attributed string, its font, and color, and returns a new attributed string,
    /// truncated to fit within the max width, with an ellipsis appended to the end.
    private static func truncateStringUntilFits(
        string: NSAttributedString,
        maxWidth: CGFloat,
        font: UIFont,
        textColor: UIColor,
    ) -> NSAttributedString {
        guard string.size().width > maxWidth else {
            return string
        }

        let ellipsesUnicode = NSMutableAttributedString(string: "\u{2026}")
        ellipsesUnicode.addAttribute(.font, value: font, range: ellipsesUnicode.entireRange)
        ellipsesUnicode.addAttribute(
            .foregroundColor,
            value: textColor,
            range: ellipsesUnicode.entireRange,
        )
        let ellipsesWidth = ellipsesUnicode.size().width
        let newMaxWidth = maxWidth - ellipsesWidth

        let truncatedString: NSMutableAttributedString = NSMutableAttributedString(attributedString: string)

        // Since NSAttributedStrings count UTF-16 code points, we should
        // use rangeOfComposedCharacterSequences to delete the total range
        // for a single "visible" char to avoid breaking up emojis.
        while truncatedString.size().width > newMaxWidth {
            let totalCharRange = (truncatedString.string as NSString).rangeOfComposedCharacterSequences(
                for:
                NSRange(
                    location: truncatedString.length - 1,
                    length: 1,
                ),
            )
            truncatedString.deleteCharacters(in: totalCharRange)
        }

        truncatedString.append(ellipsesUnicode)
        return truncatedString
    }

    /// Takes an attributed string & its properties, and formats it correctly to prevent wrapping of the highlighted range.
    /// Any part of the attributed string outside of the highlight range can wrap as usual, but the highlighted range should
    /// stay on one line and truncate using truncateStringUntilFits().
    /// For example, "Jane (Engineer)" with () indicating the highlighted range, should either stay on one line width permitting, or become:
    ///
    /// "Jane
    /// (Engineer)"
    ///
    /// If the member label is too long for the given space on the next line it should become:
    ///
    /// "Jane
    /// (Eng...)"
    ///
    /// A long profile name might look like this:
    ///  "Jane Long Profile
    ///  Name (Engineer)"
    ///
    ///  or, if less wide,
    ///  "Jane
    ///  Long
    ///  Profile
    ///  Name
    ///  (Eng...)"
    ///
    ///  A truncated member label should always be on its own line.
    private static func formatCapsuleString(
        attributedString: NSAttributedString,
        highlightRange: NSRange,
        highlightFont: UIFont,
        textColor: UIColor,
        maxWidth: CGFloat,
    ) -> (NSAttributedString, NSRange)? {
        let totalStringWidth = attributedString.size().width
        let highlightedString = attributedString.attributedSubstring(from: highlightRange)
        let highlightedStringWidth = highlightedString.size().width

        let nonHighlightRange = NSRange(location: 0, length: highlightRange.location)
        let nonHighlightString = attributedString.attributedSubstring(from: nonHighlightRange)

        let breakString = NSAttributedString(string: "\n")

        // If highlight text width or total string width is greater than line width,
        // move highlight to the next line to avoid wrapping, and truncate it if needed.
        if highlightedStringWidth > maxWidth || totalStringWidth > maxWidth {
            let truncatedHighlightString = Self.truncateStringUntilFits(
                string: highlightedString,
                maxWidth: maxWidth,
                font: highlightFont,
                textColor: textColor,
            )

            if !nonHighlightString.isEmpty {
                let newTotalString = nonHighlightString + breakString + truncatedHighlightString
                let newHighlightRange = (newTotalString.string as NSString).range(of: truncatedHighlightString.string)
                return (newTotalString, newHighlightRange)
            }

            return (truncatedHighlightString, truncatedHighlightString.entireRange)
        }

        // Everything fits on one line! Return as-is.
        return (attributedString, highlightRange)
    }

    private func textContainerForFormattedString(
        layoutManager: NSLayoutManager,
        textStorage: NSTextStorage,
        size: CGSize,
    ) -> NSTextContainer {
        let textContainer = NSTextContainer(size: size)
        textContainer.lineFragmentPadding = 0
        textContainer.maximumNumberOfLines = self.numberOfLines
        textContainer.lineBreakMode = self.lineBreakMode
        layoutManager.addTextContainer(textContainer)
        textStorage.addLayoutManager(layoutManager)

        return textContainer
    }

    private func calculateHorizontalOffset() -> CGFloat {
        // We only need to offset the capsule & text horizontally if the edge of the view
        // might cut it off because its naturally aligned.
        let needsHorizontalOffset = textAlignment == .natural
        if needsHorizontalOffset {
            return CurrentAppContext().isRTL ? -Self.horizontalOffset : Self.horizontalOffset
        }
        return 0
    }

    override public func drawText(in rect: CGRect) {
        guard let attributedText, let textColor else {
            return super.drawText(in: rect)
        }

        owsAssertDebug(numberOfLines == 0 || numberOfLines == 1, "CVCapsule wrapping behavior undefined")

        let hOffset = calculateHorizontalOffset()
        let maxWidth = rect.width - (2 * Self.horizontalCapsuleInset + abs(hOffset))
        let formattedStringData = CVCapsuleLabel.formatCapsuleString(
            attributedString: attributedText,
            highlightRange: highlightRange,
            highlightFont: highlightFont,
            textColor: textColor,
            maxWidth: maxWidth,
        )

        guard let (formattedAttributedString, newHighlightRange) = formattedStringData else {
            return super.drawText(in: rect)
        }

        let layoutManager = NSLayoutManager()
        let textStorage = NSTextStorage(attributedString: formattedAttributedString)
        let textContainer = textContainerForFormattedString(
            layoutManager: layoutManager,
            textStorage: textStorage,
            size: rect.size,
        )
        let highlightGlyphRange = layoutManager.glyphRange(forCharacterRange: newHighlightRange, actualCharacterRange: nil)
        let highlightColor = capsuleColor
        layoutManager.enumerateEnclosingRects(forGlyphRange: highlightGlyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { rect, _ in
            let vCapsuleOffset = -Self.verticalCapsuleInset + Self.verticalOffset
            let roundedRect = rect.offsetBy(
                dx: hOffset,
                dy: vCapsuleOffset,
            ).insetBy(
                dx: -Self.horizontalCapsuleInset,
                dy: -Self.verticalCapsuleInset,
            )
            let path = UIBezierPath(roundedRect: roundedRect, cornerRadius: roundedRect.height / 2)
            highlightColor.setFill()
            path.fill()
            layoutManager.drawGlyphs(forGlyphRange: highlightGlyphRange, at: CGPoint(x: hOffset, y: Self.verticalOffset))
        }

        let newNonHighlightRange = NSRange(location: 0, length: newHighlightRange.location)
        let nonHighlightGlyphRange = layoutManager.glyphRange(forCharacterRange: newNonHighlightRange, actualCharacterRange: nil)
        layoutManager.drawGlyphs(forGlyphRange: nonHighlightGlyphRange, at: CGPoint(x: 0, y: Self.verticalOffset))
    }

    override public var intrinsicContentSize: CGSize {
        return labelSize(maxWidth: .greatestFiniteMagnitude)
    }

    public static func measureLabel(
        attributedText: NSAttributedString,
        font: UIFont,
        highlightRange: NSRange,
        highlightFont: UIFont,
        presentationContext: CVCapsuleLabel.PresentationContext,
        maxWidth: CGFloat,
        signalSymbolRange: NSRange?,
    ) -> CGSize {
        let label = CVCapsuleLabel(
            attributedText: attributedText,
            textColor: .black,
            font: font,
            highlightRange: highlightRange,
            highlightFont: highlightFont,
            axLabelPrefix: nil,
            presentationContext: presentationContext,
            signalSymbolRange: signalSymbolRange,
            onTap: nil,
        )
        return label.labelSize(maxWidth: maxWidth)
    }

    public func labelSize(maxWidth: CGFloat) -> CGSize {
        guard let attributedText, !attributedText.isEmpty else { return .zero }
        let hOffset = calculateHorizontalOffset()

        let maxWidthMinusInsets = maxWidth - (abs(hOffset) + Self.horizontalCapsuleInset * 2)

        owsAssertDebug(numberOfLines == 0 || numberOfLines == 1, "CVCapsule wrapping behavior undefined")

        let formattedStringData = CVCapsuleLabel.formatCapsuleString(
            attributedString: attributedText,
            highlightRange: highlightRange,
            highlightFont: highlightFont,
            textColor: textColor,
            maxWidth: maxWidthMinusInsets,
        )

        guard let (formattedAttributedString, _) = formattedStringData else {
            return .zero
        }

        let layoutManager = NSLayoutManager()
        let size = CGSize(width: maxWidthMinusInsets, height: .greatestFiniteMagnitude)

        let textStorage = NSTextStorage(attributedString: formattedAttributedString)
        let textContainer = textContainerForFormattedString(
            layoutManager: layoutManager,
            textStorage: textStorage,
            size: size,
        )

        let measureSize = layoutManager.usedRect(for: textContainer).size.ceil
        let finalHeight = measureSize.height + Self.verticalOffset + Self.verticalCapsuleInset * 2
        let finalWidth = measureSize.width + Self.horizontalCapsuleInset * 2 + abs(hOffset)
        return CGSize(width: finalWidth, height: finalHeight)
    }

    override public var accessibilityLabel: String? {
        get {
            if let axLabelPrefix, let text = self.text {
                return axLabelPrefix + text
            }
            return super.accessibilityLabel
        }
        set { super.accessibilityLabel = newValue }
    }

    override public var accessibilityTraits: UIAccessibilityTraits {
        get {
            var axTraits = super.accessibilityTraits
            if onTap != nil {
                axTraits.insert(.button)
            }
            return axTraits
        }
        set {
            super.accessibilityTraits = newValue
        }
    }
}