TM-SGNL-iOS/SignalServiceKit/Messages/BodyRanges/HydratedMessageBody.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

910 lines
36 KiB
Swift

//
// 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)
}
internal 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: [])
}
internal init(
messageBody: MessageBody,
mentionHydrator: MentionHydrator,
isRTL: Bool = CurrentAppContext().isRTL
) {
guard messageBody.text.isEmpty.negated else {
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 configuation 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
)
}
internal 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
internal 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
)
}
internal 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
internal 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
}
}