Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Messages/BodyRanges/HydratedMessageBody.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import AVFAudio
import Foundation
public import LibSignalClient

/// The result of stripping, filtering, and hydrating mentions in a `MessageBody`.
/// This object can be held durably in memory as a way to cache mention hydrations
/// and other expensive string operations, and can subsequently be transformed
/// into string and attributed string values for display.
public class HydratedMessageBody: Equatable, Hashable {

    public typealias Style = MessageBodyRanges.Style
    public typealias SingleStyle = MessageBodyRanges.SingleStyle
    public typealias CollapsedStyle = MessageBodyRanges.CollapsedStyle

    private let hydratedText: String
    private let unhydratedMentions: [NSRangedValue<UnhydratedMentionAttribute>]
    private let mentionAttributes: [NSRangedValue<HydratedMentionAttribute>]
    private let styleAttributes: [NSRangedValue<StyleAttribute>]

    public var isEmpty: Bool { hydratedText.isEmpty }

    public static func ==(lhs: HydratedMessageBody, rhs: HydratedMessageBody) -> Bool {
        return lhs.hydratedText == rhs.hydratedText
            && lhs.mentionAttributes == rhs.mentionAttributes
            && lhs.styleAttributes == rhs.styleAttributes
            && lhs.unhydratedMentions == rhs.unhydratedMentions
    }

    public func hash(into hasher: inout Hasher) {
        hasher.combine(hydratedText)
        hasher.combine(unhydratedMentions)
        hasher.combine(mentionAttributes)
        hasher.combine(styleAttributes)
    }

    init(
        hydratedText: String,
        unhydratedMentions: [NSRangedValue<UnhydratedMentionAttribute>] = [],
        mentionAttributes: [NSRangedValue<HydratedMentionAttribute>],
        styleAttributes: [NSRangedValue<StyleAttribute>],
    ) {
        self.hydratedText = hydratedText
        self.unhydratedMentions = unhydratedMentions
        self.mentionAttributes = mentionAttributes
        self.styleAttributes = styleAttributes
    }

    public static func fromPlaintextWithoutRanges(_ text: String) -> HydratedMessageBody {
        return HydratedMessageBody(hydratedText: text, mentionAttributes: [], styleAttributes: [])
    }

    init(
        messageBody: MessageBody,
        mentionHydrator: MentionHydrator,
        isRTL: Bool = CurrentAppContext().isRTL,
    ) {
        if messageBody.text.isEmpty {
            self.hydratedText = ""
            self.unhydratedMentions = []
            self.mentionAttributes = []
            self.styleAttributes = []
            return
        }

        var mentionsInOriginal = messageBody.ranges.orderedMentions
        var stylesInOriginal = messageBody.ranges.collapsedStyles

        let finalText = NSMutableString(string: messageBody.text)
        let startLength = finalText.length
        var unhydratedMentions = [NSRangedValue<UnhydratedMentionAttribute>]()
        var finalStyleAttributes = [NSRangedValue<StyleAttribute>]()
        var finalMentionAttributes = [NSRangedValue<HydratedMentionAttribute>]()

        var rangeOffset = 0

        struct ProcessingStyle {
            let originalRange: NSRange
            let newRange: NSRange
            let style: CollapsedStyle
        }
        var styleAtCurrentIndex: ProcessingStyle?

        for currentIndex in 0..<startLength {
            // If we are past the end, apply the active style to the final result
            // and drop.
            if
                let style = styleAtCurrentIndex,
                currentIndex >= style.originalRange.upperBound
            {
                finalStyleAttributes.append(.init(
                    StyleAttribute.fromCollapsedStyle(style.style),
                    range: style.newRange,
                ))
                styleAtCurrentIndex = nil
            }
            // Check for any new styles starting at the current index.
            if stylesInOriginal.first?.range.contains(currentIndex) == true {
                let style = stylesInOriginal.removeFirst()
                let originalRange = style.range
                styleAtCurrentIndex = .init(
                    originalRange: originalRange,
                    newRange: NSRange(
                        location: originalRange.location + rangeOffset,
                        length: originalRange.length,
                    ),
                    style: style.value,
                )
            }

            // Check for any mentions at the current index.
            // Mentions can't overlap, so we don't need a while loop to check for multiple.
            guard
                let mention = mentionsInOriginal.first,

                mention.range.contains(currentIndex)
                || mention.range.location == currentIndex

            else {
                // No mentions, so no additional logic needed, just go to the next index.
                continue
            }
            mentionsInOriginal.removeFirst()

            let newMentionRange = NSRange(
                location: mention.range.location + rangeOffset,
                length: mention.range.length,
            )

            let finalMentionLength: Int
            let mentionOffsetDelta: Int
            switch mentionHydrator(mention.value) {
            case .preserveMention:
                // Preserve the mention without replacement and proceed.
                unhydratedMentions.append(.init(
                    UnhydratedMentionAttribute.fromOriginalRange(mention.range, mentionAci: mention.value),
                    range: newMentionRange,
                ))
                continue
            case let .hydrate(displayName):
                let mentionPlaintext: String
                if isRTL {
                    mentionPlaintext = displayName + Mention.prefix
                } else {
                    mentionPlaintext = Mention.prefix + displayName
                }
                finalMentionLength = (mentionPlaintext as NSString).length
                // Make sure we don't have any illegal mention ranges; if so skip them.
                if newMentionRange.upperBound <= finalText.length {
                    mentionOffsetDelta = finalMentionLength - mention.range.length
                    finalText.replaceCharacters(in: newMentionRange, with: mentionPlaintext)
                    finalMentionAttributes.append(.init(
                        HydratedMentionAttribute.fromOriginalRange(
                            mention.range,
                            mentionAci: mention.value,
                            displayName: displayName,
                        ),
                        range: NSRange(location: newMentionRange.location, length: finalMentionLength),
                    ))
                } else {
                    mentionOffsetDelta = 0
                }
            }
            rangeOffset += mentionOffsetDelta

            // We have to adjust style ranges for the active style
            if let style = styleAtCurrentIndex {
                if style.originalRange.upperBound <= mention.range.upperBound {
                    // If the style ended inside (or right at the end of) the mention,
                    // it should now end at the end of the replacement text.
                    let finalLength = (newMentionRange.location + finalMentionLength) - style.newRange.location
                    finalStyleAttributes.append(.init(
                        StyleAttribute.fromCollapsedStyle(style.style),
                        range: NSRange(
                            location: style.newRange.location,
                            length: finalLength,
                        ),
                    ))

                    // We are done with it, now.
                    styleAtCurrentIndex = nil
                } else {
                    // The original style ends past the mention; extend its
                    // length by the right amount, but keep it in
                    // the current styles being walked through.
                    styleAtCurrentIndex = .init(
                        originalRange: style.originalRange,
                        newRange: NSRange(
                            location: style.newRange.location,
                            length: style.newRange.length + mentionOffsetDelta,
                        ),
                        style: style.style,
                    )
                }
            }
        }

        if let style = styleAtCurrentIndex {
            // Styles that ran right to the end (or overran) should be finalized.
            let finalRange = NSRange(
                location: style.newRange.location,
                length: finalText.length - style.newRange.location,
            )
            finalStyleAttributes.append(.init(
                StyleAttribute.fromCollapsedStyle(style.style),
                range: finalRange,
            ))
        }

        self.hydratedText = finalText as String
        self.unhydratedMentions = unhydratedMentions
        self.styleAttributes = finalStyleAttributes
        self.mentionAttributes = finalMentionAttributes
    }

    // MARK: - Displaying as NSAttributedString

    public struct DisplayConfiguration {
        public let baseFont: UIFont
        public let baseTextColor: ThemedColor
        public let mention: MentionDisplayConfiguration
        public let style: StyleDisplayConfiguration

        public struct SearchRanges: Equatable {
            public let matchingBackgroundColor: ThemedColor
            public let matchingForegroundColor: ThemedColor
            public let matchedRanges: [NSRange]

            public init(
                matchingBackgroundColor: ThemedColor,
                matchingForegroundColor: ThemedColor,
                matchedRanges: [NSRange],
            ) {
                self.matchingBackgroundColor = matchingBackgroundColor
                self.matchingForegroundColor = matchingForegroundColor
                self.matchedRanges = matchedRanges
            }

            public func hashForSpoilerFrames(into hasher: inout Hasher) {
                hasher.combine(matchingBackgroundColor)
                hasher.combine(matchedRanges)
            }

            fileprivate static let configKey = NSAttributedString.Key("OWS.searchRange")

            public func apply(
                _ string: NSMutableAttributedString,
                isDarkThemeEnabled: Bool,
            ) {
                for searchMatchRange in matchedRanges {
                    string.addAttributes(
                        [
                            .backgroundColor: matchingBackgroundColor.color(isDarkThemeEnabled: isDarkThemeEnabled),
                            .foregroundColor: matchingForegroundColor.color(isDarkThemeEnabled: isDarkThemeEnabled),
                            Self.configKey: self as Any,
                        ],
                        range: searchMatchRange,
                    )
                }
            }
        }

        public let searchRanges: SearchRanges?

        public init(
            baseFont: UIFont,
            baseTextColor: ThemedColor,
            mention: MentionDisplayConfiguration,
            style: StyleDisplayConfiguration,
            searchRanges: SearchRanges?,
        ) {
            self.baseFont = baseFont
            self.baseTextColor = baseTextColor
            self.mention = mention
            self.style = style
            self.searchRanges = searchRanges
        }

        /**
         * Creates a new config using shared values.
         *
         * - parameter baseFont: Font to use for unstyled, non-mention text.
         * - parameter baseTextColor:
         * - parameter mentionFont: The font to use for mention text.
         *   If nil, baseFont is used.
         * - parameter mentionForegroundColor: The color to use for mention text.
         *   If nil, baseTextColor is used.
         * - parameter mentionBackgroundColor: The color to use to "highlight" mentions.
         *   If nil, no highlight is applied to mentions.
         * - parameter spoilerAnimationColorOverride: If set, animated spoiler particles
         *   will use this color instead of the baseTextColor.
         * - parameter revealedSpoilerBgColor: The color to use to "highlight" revealed spoilers.
         *   If nil, no highlight is applied to revealed spoilers.
         * - parameter revealAllSpoilers: If true, all spoilers will be revealed and
         *   `revealedSpoilerIds` will be ignored.
         * - parameter revealedSpoilerIds: IDs of spoiler ranges that should be revealed.
         *   Ignored if `revealAllSpoilers is true`.
         * - parameter searchRanges: Ranges to highlight as search results.
         */
        public init(
            baseFont: UIFont,
            baseTextColor: ThemedColor,
            mentionFont: UIFont? = nil,
            mentionForegroundColor: ThemedColor? = nil,
            mentionBackgroundColor: ThemedColor? = nil,
            spoilerAnimationColorOverride: ThemedColor? = nil,
            revealedSpoilerBgColor: ThemedColor? = nil,
            revealAllSpoilers: Bool = false,
            revealedSpoilerIds: Set<StyleIdType> = Set(),
            searchRanges: SearchRanges? = nil,
            useAnimatedSpoilers: Bool,
        ) {
            self.init(
                baseFont: baseFont,
                baseTextColor: baseTextColor,
                mention: .init(
                    font: mentionFont ?? baseFont,
                    foregroundColor: mentionForegroundColor ?? baseTextColor,
                    backgroundColor: mentionBackgroundColor,
                ),
                style: .init(
                    baseFont: baseFont,
                    textColor: baseTextColor,
                    spoilerAnimationColorOverride: spoilerAnimationColorOverride,
                    revealedSpoilerBgColor: revealedSpoilerBgColor,
                    revealAllIds: revealAllSpoilers,
                    revealedIds: revealedSpoilerIds,
                    useAnimatedSpoilers: useAnimatedSpoilers,
                ),
                searchRanges: searchRanges,
            )
        }

        public func hashForSpoilerFrames(into hasher: inout Hasher) {
            searchRanges?.hashForSpoilerFrames(into: &hasher)
            style.hashForSpoilerFrames(into: &hasher)
        }

        public var sizingCacheKey: String {
            return "\(baseFont.fontName)\(baseFont.pointSize)\(mention.font.fontName)\(mention.font.pointSize)\(style.baseFont.fontName)\(style.baseFont.pointSize)"
        }
    }

    /// If baseFont or baseTextColor are not provided, the values in the style display configuration are used.
    public func asAttributedStringForDisplay(
        config: DisplayConfiguration,
        baseFont: UIFont? = nil,
        baseTextColor: UIColor? = nil,
        textAlignment: NSTextAlignment? = nil,
        isDarkThemeEnabled: Bool,
    ) -> NSAttributedString {
        let baseFont = baseFont ?? config.baseFont
        let baseTextColor = baseTextColor ?? config.baseTextColor.color(isDarkThemeEnabled: isDarkThemeEnabled)

        var baseAttributes: [NSAttributedString.Key: Any] = [
            .font: baseFont,
            .foregroundColor: baseTextColor,
        ]
        if let textAlignment {
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = textAlignment
            baseAttributes[.paragraphStyle] = paragraphStyle
        }
        let string = NSMutableAttributedString(
            string: hydratedText,
            attributes: baseAttributes,
        )
        return Self.applyAttributes(
            on: string,
            mentionAttributes: mentionAttributes,
            styleAttributes: styleAttributes,
            config: config,
            isDarkThemeEnabled: isDarkThemeEnabled,
        )
    }

    static func applyAttributes(
        on string: NSMutableAttributedString,
        mentionAttributes: [NSRangedValue<HydratedMentionAttribute>],
        styleAttributes: [NSRangedValue<StyleAttribute>],
        config: HydratedMessageBody.DisplayConfiguration,
        isDarkThemeEnabled: Bool,
    ) -> NSMutableAttributedString {
        // Start by removing the background color attribute on the
        // whole string. This is brittle but a big efficiency gain.

        // Consider the scenario where we have a mention under a spoiler
        // and reveal the spoiler.
        // The attributed string we get will have the spoiler background.
        // If we didn't have a mention, the style application would need
        // to wipe the background color in order to reveal; but if we do
        // have a mention doing so will clear the mention style too!

        // The most efficient solution is to always start by clearing
        // out the background, so that the revealed spoiler knows it can
        // do nothing, and it won't wipe the mention attribute.

        // This should be revisited in the future with a more complex solution
        // if there are more overlapping attributes; as of writing only the
        // background color is used by mentions and styles and search.
        string.removeAttribute(.backgroundColor, range: string.entireRange)

        mentionAttributes.forEach {
            $0.value.applyAttributes(
                to: string,
                at: $0.range,
                config: config.mention,
                isDarkThemeEnabled: isDarkThemeEnabled,
            )
        }

        // Search takes priority over mentions, but not spoiler styles.
        config.searchRanges?.apply(string, isDarkThemeEnabled: isDarkThemeEnabled)

        styleAttributes.forEach {
            $0.value.applyAttributes(
                to: string,
                at: $0.range,
                config: config.style,
                searchRanges: config.searchRanges,
                isDarkThemeEnabled: isDarkThemeEnabled,
            )
        }
        return string
    }

    // MARK: - Displaying as Plaintext

    public func asPlaintext() -> String {
        let mutableString = NSMutableString(string: hydratedText)
        // Reverse the sorted array so length changes that happen due to
        // replacement don't affect later ranges.
        styleAttributes.reversed().forEach {
            guard $0.value.style.contains(.spoiler) else {
                return
            }
            $0.value.applyPlaintextSpoiler(to: mutableString, at: $0.range)
        }
        return mutableString as String
    }

    // MARK: - Style-only (for stories)

    public func asStyleOnlyBody() -> StyleOnlyMessageBody {
        // Concept of "forwarding" is mentions only and therefore irrelevant;
        // we are really only mapping the styles here.
        return StyleOnlyMessageBody(messageBody: self.asMessageBodyForForwarding())
    }

    // MARK: - Forwarding

    public func asMessageBodyForForwarding(
        preservingAllMentions: Bool = false,
    ) -> MessageBody {
        var mentionsDict = [NSRange: Aci]()
        unhydratedMentions.forEach {
            mentionsDict[$0.range] = $0.value.mentionAci
        }
        if preservingAllMentions {
            mentionAttributes.forEach {
                mentionsDict[$0.range] = $0.value.mentionAci
            }
        }

        return MessageBody(
            text: hydratedText,
            ranges: MessageBodyRanges(
                mentions: mentionsDict,
                styles: Self.flattenStylesPreservingSharedIds(styleAttributes),
            ),
        )
    }

    // MARK: - Editing

    func asEditableMessageBody() -> EditableMessageBodyTextStorage.Body {
        var mentions = [NSRange: Aci]()
        self.mentionAttributes.forEach {
            mentions[$0.range] = $0.value.mentionAci
        }
        self.unhydratedMentions.forEach {
            mentions[$0.range] = $0.value.mentionAci
        }
        var flattenedStyles = [NSRangedValue<SingleStyle>]()
        var runningStyles = [SingleStyle: (StyleIdType, NSRange)]()

        styleAttributes.forEach { (styleAttribute: NSRangedValue<StyleAttribute>) in
            SingleStyle.allCases.forEach { style in
                guard styleAttribute.value.style.contains(style: style), let id = styleAttribute.value.ids[style] else {
                    return
                }
                if let runningStyle: (StyleIdType, NSRange) = runningStyles[style] {
                    // Append to the running style.
                    if runningStyle.0 == id {
                        runningStyles[style] = (id, runningStyle.1.union(styleAttribute.range))
                    } else {
                        flattenedStyles.append(.init(style, range: runningStyle.1))
                        runningStyles[style] = (id, styleAttribute.range)
                    }
                } else {
                    runningStyles[style] = (id, styleAttribute.range)
                }
            }
        }
        flattenedStyles.append(
            contentsOf: runningStyles
                .map({ style, values in
                    return NSRangedValue<SingleStyle>(style, range: values.1)
                }),
        )
        flattenedStyles.sort(by: { $0.range.location < $1.range.location })
        return .init(
            hydratedText: hydratedText,
            mentions: mentions,
            flattenedStyles: flattenedStyles,
        )
    }

    // MARK: - Adding prefix

    public func addingPrefix(_ prefix: String) -> HydratedMessageBody {
        return addingStyledPrefix(.init(plaintext: prefix))
    }

    public func addingStyledPrefix(_ prefix: StyleOnlyMessageBody) -> HydratedMessageBody {
        let offset = (prefix.text as NSString).length
        let prefixStyles: [NSRangedValue<StyleAttribute>] = prefix.collapsedStyles.map {
            return .init(.fromCollapsedStyle($0.value), range: $0.range)
        }
        return HydratedMessageBody(
            hydratedText: prefix.text + hydratedText,
            unhydratedMentions: unhydratedMentions.map { $0.offset(by: offset) },
            mentionAttributes: mentionAttributes.map { $0.offset(by: offset) },
            styleAttributes: prefixStyles + styleAttributes.map { $0.offset(by: offset) },
        )
    }

    public var nilIfEmpty: HydratedMessageBody? {
        if self.hydratedText.isEmpty {
            return nil
        }
        return self
    }

    // MARK: - Truncation

    /// NOTE: if there is a mention at the truncation point, we instead truncate sooner
    /// so as to not cut off mid-mention.
    public func truncatingIfNeeded(
        maxGlyphCount: Int,
        truncationSuffix: String,
    ) -> HydratedMessageBody? {
        guard var truncatedBody = hydratedText.trimmedIfNeeded(maxGlyphCount: maxGlyphCount) else {
            return nil
        }

        // Input is defined in grapheme clusters (doesn't cut emoji off)
        // but mentions and styles are defined in utf16 character counts.
        var truncatedUtf16Length = truncatedBody.utf16.count

        for mentionAttribute in self.mentionAttributes {
            if mentionAttribute.range.contains(truncatedUtf16Length) {
                // There's a mention overlapping our normal truncate point, we want to truncate sooner
                // so we don't "split" the mention.
                truncatedBody = (truncatedBody as NSString).substring(to: mentionAttribute.range.location)
                truncatedUtf16Length = truncatedBody.utf16.count
                break
            }
            if mentionAttribute.range.location > truncatedUtf16Length {
                // mentions are ordered; can early exit if we pass it.
                break
            }
        }

        var mentionHydrationStrings = [Aci: String]()
        let mentions = self.mentionAttributes.filter({
            guard $0.range.location < truncatedUtf16Length else {
                return false
            }
            mentionHydrationStrings[$0.value.mentionAci] = $0.value.displayName
            return true
        })
        let unhydratedMentions = self.unhydratedMentions.filter { $0.range.upperBound <= truncatedUtf16Length }
        let styles = self.styleAttributes.compactMap { styleAttribute -> NSRangedValue<StyleAttribute>? in
            if styleAttribute.range.location > truncatedUtf16Length {
                return nil
            } else if styleAttribute.range.upperBound <= truncatedUtf16Length {
                return styleAttribute
            } else {
                return .init(
                    styleAttribute.value,
                    range: NSRange(
                        location: styleAttribute.range.location,
                        length: truncatedUtf16Length - styleAttribute.range.location,
                    ),
                )
            }
        }

        let newSelf = HydratedMessageBody(
            hydratedText: truncatedBody + truncationSuffix,
            unhydratedMentions: unhydratedMentions,
            mentionAttributes: mentions,
            styleAttributes: styles,
        )
        // Strip. It's less efficient but avoids code repetition to go through message body.
        return newSelf
            .asMessageBodyForForwarding(preservingAllMentions: true)
            .filterStringForDisplay()
            .hydrating(mentionHydrator: { mentionAci in
                guard let string = mentionHydrationStrings[mentionAci] else {
                    return .preserveMention
                }
                return .hydrate(string)
            })
    }

    // MARK: - Spoiler Ranges

    public var hasSpoilerRangesToAnimate: Bool {
        return styleAttributes.contains(where: { $0.value.style.contains(style: .spoiler) })
    }

    public struct AnimatableSpoilerRange {
        public let range: NSRange
        public let color: ThemedColor
        public let isSearchResult: Bool
    }

    public func spoilerRangesForAnimation(
        config: DisplayConfiguration,
    ) -> [AnimatableSpoilerRange] {
        // We want to collapse adjacent ranges because they should
        // all animate together even if they are distinct ranges
        // for the purposes of revealing. Otherwise we'd get
        // abrupt boundaries.
        var finalRanges = [NSRange]()
        var ongoingRange: NSRange?
        for styleAttribute in styleAttributes {
            guard
                styleAttribute.value.style.contains(style: .spoiler),
                let spoilerId = styleAttribute.value.ids[.spoiler],
                !(config.style.revealAllIds || config.style.revealedIds.contains(spoilerId))
            else {
                continue
            }
            guard let currentRange = ongoingRange else {
                ongoingRange = styleAttribute.range
                continue
            }
            if currentRange.upperBound >= styleAttribute.range.location {
                ongoingRange = currentRange.union(styleAttribute.range)
            } else {
                finalRanges.append(currentRange)
                ongoingRange = styleAttribute.range
            }
        }
        if let ongoingRange {
            finalRanges.append(ongoingRange)
        }

        guard let searchConfig = config.searchRanges, !searchConfig.matchedRanges.isEmpty else {
            return finalRanges.map { .init(range: $0, color: config.style.spoilerColor, isSearchResult: false) }
        }

        var coloredRanges = [AnimatableSpoilerRange]()
        for spoilerRange in finalRanges {
            var remainingSpoilerRange = spoilerRange
            searchRangeLoop: for searchRange in searchConfig.matchedRanges {
                if let intersection = remainingSpoilerRange.intersection(searchRange), intersection.length > 0 {
                    // First add any part of the spoiler range before the search range.
                    if remainingSpoilerRange.location < intersection.location {
                        coloredRanges.append(.init(
                            range: NSRange(
                                location: remainingSpoilerRange.location,
                                length: intersection.location - remainingSpoilerRange.location,
                            ),
                            color: config.style.spoilerColor,
                            isSearchResult: false,
                        ))
                    }
                    // The overlapping part gets the search config's color.
                    coloredRanges.append(
                        .init(range: intersection, color: searchConfig.matchingBackgroundColor, isSearchResult: true),
                    )
                    if spoilerRange.upperBound <= intersection.upperBound {
                        break searchRangeLoop
                    } else {
                        remainingSpoilerRange = NSRange(
                            location: intersection.upperBound,
                            length: remainingSpoilerRange.upperBound - intersection.upperBound,
                        )
                    }
                } else if searchRange.location >= remainingSpoilerRange.upperBound {
                    break searchRangeLoop
                } else {
                    continue
                }
            }
            if remainingSpoilerRange.length > 0 {
                coloredRanges.append(.init(range: remainingSpoilerRange, color: config.style.spoilerColor, isSearchResult: false))
            }
        }
        return coloredRanges
    }

    // MARK: - Tappable items

    public enum TappableItem {
        public struct Mention {
            public let range: NSRange
            public let mentionAci: Aci
        }

        public struct UnrevealedSpoiler {
            public let range: NSRange
            public let id: StyleIdType
        }

        case mention(Mention)
        case unrevealedSpoiler(UnrevealedSpoiler)
        case data(TextCheckingDataItem)
    }

    public func tappableItems(
        revealedSpoilerIds: Set<Int>,
        dataDetector: NSDataDetector?,
    ) -> [TappableItem] {
        return Self.tappableItems(
            text: hydratedText,
            mentionAttributes: mentionAttributes,
            styleAttributes: styleAttributes,
            revealedSpoilerIds: revealedSpoilerIds,
            dataDetector: dataDetector,
        )
    }

    static func tappableItems(
        text: String,
        mentionAttributes: [NSRangedValue<HydratedMentionAttribute>],
        styleAttributes: [NSRangedValue<StyleAttribute>],
        revealedSpoilerIds: Set<Int>,
        dataDetector: NSDataDetector?,
    ) -> [TappableItem] {
        // We "cheat" by using NSAttributedString to deal with overlapping
        // ranges for us. We add our items and their ranges as attributes,
        // then enumerate attributes to deal with overlaps.
        let attrString = NSMutableAttributedString(string: "")

        func setRange(
            value: Any,
            key: NSAttributedString.Key,
            range: NSRange,
        ) {
            if range.upperBound > attrString.length {
                attrString.append(String(repeating: " ", count: range.upperBound - attrString.length))
            }
            attrString.addAttribute(key, value: value, range: range)
        }

        // These are used in a string tied to the scope of this
        // function; no need to be too careful about them.
        let unrevealedSpoilerKey = NSAttributedString.Key("ows.spoiler")
        let mentionKey = NSAttributedString.Key("ows.mention")
        let dataKey = NSAttributedString.Key("ows.data")

        styleAttributes.forEach {
            if
                $0.value.style.contains(.spoiler),
                let spoilerId = $0.value.ids[.spoiler],
                revealedSpoilerIds.contains(spoilerId).negated
            {
                setRange(
                    value: TappableItem.UnrevealedSpoiler(range: $0.range, id: spoilerId),
                    key: unrevealedSpoilerKey,
                    range: $0.range,
                )
            }
        }
        mentionAttributes.forEach {
            setRange(
                value: TappableItem.Mention(range: $0.range, mentionAci: $0.value.mentionAci),
                key: mentionKey,
                range: $0.range,
            )
        }

        let dataItems = TextCheckingDataItem.detectedItems(in: text, using: dataDetector)
        dataItems.forEach {
            setRange(
                value: $0,
                key: dataKey,
                range: $0.range,
            )
        }

        var items = [TappableItem]()
        attrString.enumerateAttributes(in: attrString.entireRange) { attrs, range, _ in
            // Spoilers are highest priority; if we have those, stick with them.
            // Then comes mentions and last data items.
            // The attributed string will have split out overlapping subranges for us.
            if let unrevealedSpoiler = attrs[unrevealedSpoilerKey] as? TappableItem.UnrevealedSpoiler {
                items.append(.unrevealedSpoiler(.init(range: range, id: unrevealedSpoiler.id)))
            } else if let mention = attrs[mentionKey] as? TappableItem.Mention {
                items.append(.mention(.init(range: range, mentionAci: mention.mentionAci)))
            } else if let dataItem = attrs[dataKey] as? TextCheckingDataItem {
                items.append(.data(dataItem.copyInNewRange(range)))
            }
        }

        return items
    }

    // MARK: - Regex

    public func matches(for regex: NSRegularExpression) -> [NSRange] {
        return regex.matches(
            in: hydratedText,
            options: [.withoutAnchoringBounds],
            range: hydratedText.entireRange,
        ).map(\.range)
    }

    // MARK: - DisplayableText

    // This misdirection is because we do not want to expose hydratedText externally;
    // that makes it very easy to misuse this class as just a plaintext string.

    public var accessibilityDescription: String { hydratedText }

    public var debugDescription: String { hydratedText }

    public var utterance: AVSpeechUtterance { AVSpeechUtterance(string: hydratedText) }

    // Used for caching sizing information, so we need to cache attributes since
    // monospace affects sizing.
    public var cacheKey: String { hydratedText.description + styleAttributes.description }

    public var naturalTextAlignment: NSTextAlignment { hydratedText.naturalTextAlignment }

    public func jumbomojiCount(_ jumbomojiCounter: (String) -> UInt) -> UInt {
        if hasSpoilerRangesToAnimate {
            // Never jumbomoji anything with a spoiler in it.
            return 0
        }
        return jumbomojiCounter(hydratedText)
    }

    public func renderingSizeEstimate(_ parser: (String) -> Int) -> Int {
        return parser(hydratedText)
    }

    public func shouldAllowLinkification(
        linkDetector: NSDataDetector,
        isValidLink: (String) -> Bool,
    ) -> Bool {
        guard LinkValidator.canParseURLs(in: hydratedText) else {
            return false
        }

        for match in linkDetector.matches(in: hydratedText, options: [], range: hydratedText.entireRange) {
            guard match.url != nil else {
                continue
            }

            // We extract the exact text from the `fullText` rather than use match.url.host
            // because match.url.host actually escapes non-ascii domains into puny-code.
            //
            // But what we really want is to check the text which will ultimately be presented to
            // the user.
            let rawTextOfMatch = (hydratedText as NSString).substring(with: match.range)
            guard isValidLink(rawTextOfMatch) else {
                return false
            }
        }
        return true
    }

    // MARK: - Helpers

    static func flattenStylesPreservingSharedIds(_ styleAttributes: [NSRangedValue<StyleAttribute>]) -> [NSRangedValue<SingleStyle>] {
        var styleIdToIndex = [StyleIdType: Int]()
        var styles = [NSRangedValue<MessageBodyRanges.SingleStyle>]()
        for styleAttribute in styleAttributes {
            for singleStyle in styleAttribute.value.style.contents {
                let styleId = styleAttribute.value.ids[singleStyle]
                if
                    let styleId,
                    let styleIndexToJoinInto = styleIdToIndex[styleId],
                    let styleToJoinInto = styles[safe: styleIndexToJoinInto],
                    styleToJoinInto.value == singleStyle,
                    styleToJoinInto.range.upperBound == styleAttribute.range.location
                {
                    // Merge into an existing range with the same id.
                    styles[styleIndexToJoinInto] = .init(singleStyle, range: styleToJoinInto.range.union(styleAttribute.range))
                } else {
                    if let styleId {
                        styleIdToIndex[styleId] = styles.count
                    }
                    styles.append(.init(singleStyle, range: styleAttribute.range))
                }

            }
        }
        return styles
    }
}