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

857 lines
34 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
@objcMembers
public class MessageBody: NSObject, NSCopying, NSSecureCoding {
typealias Style = MessageBodyRanges.Style
public static var supportsSecureCoding = true
public static let mentionPlaceholder = "\u{FFFC}" // Object Replacement Character
public let text: String
public let ranges: MessageBodyRanges
public var hasMentions: Bool { ranges.hasMentions }
public init(text: String, ranges: MessageBodyRanges) {
self.text = text
self.ranges = ranges
}
public required init?(coder: NSCoder) {
guard let text = coder.decodeObject(of: NSString.self, forKey: "text") as String? else {
owsFailDebug("Missing text")
return nil
}
guard let ranges = coder.decodeObject(of: MessageBodyRanges.self, forKey: "ranges") else {
owsFailDebug("Missing ranges")
return nil
}
self.text = text
self.ranges = ranges
}
public func copy(with zone: NSZone? = nil) -> Any {
return MessageBody(text: text, ranges: ranges)
}
public func encode(with coder: NSCoder) {
coder.encode(text, forKey: "text")
coder.encode(ranges, forKey: "ranges")
}
// TODO[TextFormatting]: "plaintext" here is misleading; depending on usage we may
// want to apply some styles.
public func plaintextBody(transaction: GRDBReadTransaction) -> String {
let hydratedMessageBody = hydratingMentions(hydrator: { mentionUUID in
return .hydrate(
Self.contactsManager.displayName(
for: SignalServiceAddress(uuid: mentionUUID),
transaction: transaction.asAnyRead
),
alreadyIncludesPrefix: false
)
})
return (hydratedMessageBody.text as NSString).filterStringForDisplay()
}
public func forNewContext(
_ context: TSThread,
transaction: GRDBReadTransaction
) -> MessageBody {
guard hasMentions else {
return self
}
let isGroupThread: Bool
let recipientAddresses: Set<SignalServiceAddress>
if let groupThread = context as? TSGroupThread, groupThread.isGroupV2Thread {
isGroupThread = true
recipientAddresses = Set(groupThread.recipientAddresses(with: transaction.asAnyRead))
} else {
isGroupThread = false
recipientAddresses = .init()
}
return hydratingMentions(hydrator: { mentionUuid in
let address = SignalServiceAddress(uuid: mentionUuid)
if isGroupThread, recipientAddresses.contains(address) {
// We don't want to hydrate for mentions in the destination group;
// these can be hydrated on the fly with group member information.
// Those not in the group are hydrated with a snapshot of their
// contact info that we have; the other group members might not
// even know about them.
return .preserveMention
} else {
let displayName = Self.contactsManager.displayName(
for: address,
transaction: transaction.asAnyRead
)
return .hydrate(displayName, alreadyIncludesPrefix: false)
}
})
}
internal func hydratingMentions(
hydrator: (UUID) -> MessageBodyRanges.MentionHydrationOption,
isRTL: Bool = CurrentAppContext().isRTL
) -> MessageBody {
let text = NSMutableAttributedString(string: self.text)
let ranges = ranges.hydratingMentions(
in: text,
hydrator: hydrator,
isRTL: isRTL
)
return MessageBody(text: text.string, ranges: ranges)
}
override public func isEqual(_ object: Any?) -> Bool {
guard let other = object as? MessageBody else {
return false
}
guard text == other.text else {
return false
}
guard ranges == other.ranges else {
return false
}
return true
}
}
@objcMembers
public class MessageBodyRanges: NSObject, NSCopying, NSSecureCoding {
public static var supportsSecureCoding = true
public static let mentionPrefix = "@"
public static var empty: MessageBodyRanges { MessageBodyRanges(mentions: [:], styles: []) }
// Styles are kept separate from mentions; mentions are not allowed to overlap,
// which is partially enforced by its structure (it enforces they at least can't have
// identical ranges) while styles can overlap with each other and
// with mentions.
/// Mentions can overlap with styles but not with each other.
public let mentions: [NSRange: UUID]
public var hasMentions: Bool { !mentions.isEmpty }
/// Sorted from lowest location to highest location
public let orderedMentions: [(NSRange, UUID)]
public struct Style: OptionSet, Equatable, Hashable {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
public static let bold = Style(rawValue: 1 << 0)
public static let italic = Style(rawValue: 1 << 1)
public static let spoiler = Style(rawValue: 1 << 2)
public static let strikethrough = Style(rawValue: 1 << 3)
public static let monospace = Style(rawValue: 1 << 4)
static let attributedStringKey = NSAttributedString.Key("OWSStyle")
}
/// Sorted from lowest location to highest location.
/// Styles can overlap with mentions but not with each other.
/// If a style overlaps with _any_ part of a mention, it applies
/// to the entire length of the mention.
public let styles: [(NSRange, Style)]
public var hasRanges: Bool {
return mentions.isEmpty.negated || styles.isEmpty.negated
}
public init(mentions: [NSRange: UUID], styles: [(NSRange, Style)]) {
self.mentions = mentions
let orderedMentions = mentions.sorted(by: { $0.key.location < $1.key.location })
self.orderedMentions = orderedMentions
self.styles = Self.processStylesForInitialization(styles, orderedMentions: orderedMentions)
super.init()
}
public convenience init(protos: [SSKProtoBodyRange]) {
var mentions = [NSRange: UUID]()
var styles = [(NSRange, Style)]()
for proto in protos {
let range = NSRange(location: Int(proto.start), length: Int(proto.length))
if
let mentionUuidString = proto.mentionUuid,
let mentionUuid = UUID(uuidString: mentionUuidString)
{
mentions[range] = mentionUuid
} else if let protoStyle = proto.style {
let style: Style
switch protoStyle {
case .none:
continue
case .bold:
style = .bold
case .italic:
style = .italic
case .spoiler:
style = .spoiler
case .strikethrough:
style = .strikethrough
case .monospace:
style = .monospace
}
styles.append((range, style))
}
}
self.init(mentions: mentions, styles: styles)
}
public required init?(coder: NSCoder) {
let mentionsCount = coder.decodeInteger(forKey: "mentionsCount")
var mentions = [NSRange: UUID]()
for idx in 0..<mentionsCount {
guard let range = coder.decodeObject(of: NSValue.self, forKey: "mentions.range.\(idx)")?.rangeValue else {
owsFailDebug("Failed to decode mention range key of MessageBody")
return nil
}
guard let uuid = coder.decodeObject(of: NSUUID.self, forKey: "mentions.uuid.\(idx)") as UUID? else {
owsFailDebug("Failed to decode mention range value of MessageBody")
return nil
}
mentions[range] = uuid
}
self.mentions = mentions
let orderedMentions = mentions.sorted(by: { $0.key.location < $1.key.location })
self.orderedMentions = orderedMentions
let stylesCount: Int = {
let key = "stylesCount"
guard coder.containsValue(forKey: key) else {
// encoded values from before styles were added
// have no styles; that's fine.
return 0
}
return coder.decodeInteger(forKey: key)
}()
var styles = [(NSRange, Style)]()
for idx in 0..<stylesCount {
guard let range = coder.decodeObject(of: NSValue.self, forKey: "styles.range.\(idx)")?.rangeValue else {
owsFailDebug("Failed to decode style range key of MessageBody")
return nil
}
let style = Style(rawValue: coder.decodeInteger(forKey: "styles.style.\(idx)"))
styles.append((range, style))
}
self.styles = Self.processStylesForInitialization(styles, orderedMentions: orderedMentions)
}
private static func processStylesForInitialization(
_ styles: [(NSRange, Style)],
orderedMentions: [(NSRange, UUID)]
) -> [(NSRange, Style)] {
guard !styles.isEmpty else {
return []
}
var maxUpperBound = orderedMentions.last?.0.upperBound ?? 0
var sortedStyles = styles
.lazy
.filter { (range, _) in
guard range.location >= 0 else {
return false
}
maxUpperBound = max(maxUpperBound, range.upperBound)
return true
}
.sorted(by: { $0.0.location < $1.0.location })
var orderedMentions = orderedMentions
// Collapse all overlaps.
var finalStyles = [(NSRange, Style)]()
var collapsedStyleAtIndex: (start: Int, Style) = (start: 0, [])
var endIndexToStyle = [Int: Style]()
var styleToEndIndex = [Style: Int]()
for i in 0..<maxUpperBound {
var newStylesToApply: Style = []
func startApplyingStyles(at index: Int) {
while let (newRange, newStyle) = sortedStyles.first, newRange.location == index {
sortedStyles.removeFirst()
newStylesToApply.insert(newStyle)
// A new style starts here. But we might overlap with
// a style of the same type, in which case we should
// join them by taking the further of the two endpoints
let oldUpperBound = styleToEndIndex[newStyle]
if newRange.upperBound > (oldUpperBound ?? -1) {
styleToEndIndex[newStyle] = newRange.upperBound
var stylesAtEnd = endIndexToStyle[newRange.upperBound] ?? []
stylesAtEnd.insert(newStyle)
endIndexToStyle[newRange.upperBound] = stylesAtEnd
if let oldUpperBound {
var stylesAtExistingEnd = endIndexToStyle[oldUpperBound] ?? []
stylesAtExistingEnd.remove(newStyle)
endIndexToStyle[oldUpperBound] = stylesAtExistingEnd
}
}
}
}
startApplyingStyles(at: i)
let stylesToRemove = endIndexToStyle.removeValue(forKey: i) ?? []
if stylesToRemove.isEmpty.negated {
styleToEndIndex[stylesToRemove] = nil
}
if let mention = orderedMentions.first, mention.0.location == i {
orderedMentions.removeFirst()
if mention.0.length > 0 {
// Styles always apply to an entire mention. This means when we find
// a mention we have to do two things:
// 1) any styles that start later in the mention are treated as if they start now.
for j in i+1..<mention.0.upperBound {
startApplyingStyles(at: j)
}
// 2) make sure any active styles are extended to the end of the mention
for j in i..<mention.0.upperBound {
if let stylesEndingMidMention = endIndexToStyle.removeValue(forKey: j) {
var stylesAtNewEnd = endIndexToStyle[mention.0.upperBound] ?? []
stylesAtNewEnd.insert(stylesEndingMidMention)
endIndexToStyle[mention.0.upperBound] = stylesAtNewEnd
}
}
}
}
if newStylesToApply.isEmpty.negated || stylesToRemove.isEmpty.negated {
// We have changes. End the previous style if any, and start a new one.
var (startIndex, currentCollapsedStyle) = collapsedStyleAtIndex
if currentCollapsedStyle.isEmpty.negated {
finalStyles.append((NSRange(location: startIndex, length: i - startIndex), currentCollapsedStyle))
}
currentCollapsedStyle.remove(stylesToRemove)
currentCollapsedStyle.insert(newStylesToApply)
collapsedStyleAtIndex = (start: i, currentCollapsedStyle)
}
}
if collapsedStyleAtIndex.1.isEmpty.negated {
finalStyles.append((
NSRange(
location: collapsedStyleAtIndex.start,
length: maxUpperBound - collapsedStyleAtIndex.start
),
collapsedStyleAtIndex.1
))
}
return finalStyles
}
public func copy(with zone: NSZone? = nil) -> Any {
return MessageBodyRanges(mentions: mentions, styles: styles)
}
public func encode(with coder: NSCoder) {
coder.encode(mentions.count, forKey: "mentionsCount")
for (idx, (range, uuid)) in mentions.enumerated() {
coder.encode(NSValue(range: range), forKey: "mentions.range.\(idx)")
coder.encode(uuid, forKey: "mentions.uuid.\(idx)")
}
coder.encode(styles.count, forKey: "stylesCount")
for (idx, (range, style)) in styles.enumerated() {
coder.encode(NSValue(range: range), forKey: "styles.range.\(idx)")
coder.encode(style.rawValue, forKey: "styles.style.\(idx)")
}
}
// TODO[TextFormatting]: "plaintext" here is misleading; depending on usage we may
// want to apply some styles.
public func plaintextBody(text: String, transaction: GRDBReadTransaction) -> String {
return MessageBody(text: text, ranges: self).plaintextBody(transaction: transaction)
}
public enum MentionHydrationOption {
/// Do not hydrate the mention; this leaves the string as it was in the original,
/// which we want to do e.g. when forwarding a message with mentions from one
/// thread context to another, where we hydrate the mentions of members not in
/// the destination, but preserve mentions of shared members fully intact.
case preserveMention
/// Replace the mention range with the populated display name.
case hydrate(String, alreadyIncludesPrefix: Bool)
/// Replace the mention range with the populated display name and attributes.
case hydrateAttributed(NSAttributedString, alreadyIncludesPrefix: Bool)
}
/// Hydrates mentions (as determined by the hydrator) and sets the `.owsStyle` attribute
/// on the string for any styles but *does not* actually apply the styles. (e.g. no font attribute)
/// `.owsStyle` attributes can be converted to attributes just before display, when the font
/// and text color are available.
public func hydrateMentionsAndSetStyleAttributes(
on string: NSMutableAttributedString,
hydrator: (UUID) -> MentionHydrationOption,
isRTL: Bool = CurrentAppContext().isRTL
) {
let newStyles = hydratingMentions(
in: string,
hydrator: hydrator,
isRTL: isRTL
)
newStyles.setStyleAttributesWithoutApplying(on: string)
}
/// Applies hydrations to the provided `NSMutableAttributedString`, and returns any
/// ranges left over (styles and preserved mentions) with ranges updated to reflect the new string.
public func hydratingMentions(
in text: NSMutableAttributedString,
hydrator: (UUID) -> MentionHydrationOption,
isRTL: Bool = CurrentAppContext().isRTL
) -> MessageBodyRanges {
guard hasMentions else {
return self
}
let finalText = text
var finalMentions = [NSRange: UUID]()
var finalStyles = [(NSRange, Style)]()
var mentionsInOriginal = orderedMentions
var stylesInOriginal = styles
var rangeOffset = 0
struct ProcessingStyle {
let originalRange: NSRange
let newRange: NSRange
let style: Style
}
var styleAtCurrentIndex: ProcessingStyle?
let startLength = text.length
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
{
finalStyles.append((style.newRange, style.style))
styleAtCurrentIndex = nil
}
// Check for any new styles starting at the current index.
if stylesInOriginal.first?.0.contains(currentIndex) == true {
let (originalRange, style) = stylesInOriginal.removeFirst()
styleAtCurrentIndex = .init(
originalRange: originalRange,
newRange: NSRange(
location: originalRange.location + rangeOffset,
length: originalRange.length
),
style: style
)
}
// 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 (originalMentionRange, mentionUuid) = mentionsInOriginal.first,
(
originalMentionRange.contains(currentIndex)
|| originalMentionRange.location == currentIndex
)
else {
// No mentions, so no additional logic needed, just go to the next index.
continue
}
mentionsInOriginal.removeFirst()
let newMentionRange = NSRange(
location: originalMentionRange.location + rangeOffset,
length: originalMentionRange.length
)
let finalMentionLength: Int
let mentionOffsetDelta: Int
switch hydrator(mentionUuid) {
case .preserveMention:
// Preserve the mention without replacement and proceed.
finalMentions[newMentionRange] = mentionUuid
continue
case let .hydrate(displayName, alreadyIncludesPrefix):
let mentionPlaintext: String
if alreadyIncludesPrefix {
mentionPlaintext = displayName
} else {
if isRTL {
mentionPlaintext = displayName + MessageBodyRanges.mentionPrefix
} else {
mentionPlaintext = MessageBodyRanges.mentionPrefix + displayName
}
}
finalMentionLength = (mentionPlaintext as NSString).length
mentionOffsetDelta = finalMentionLength - originalMentionRange.length
finalText.replaceCharacters(in: newMentionRange, with: mentionPlaintext)
case let .hydrateAttributed(displayName, alreadyIncludesPrefix):
let mentionString: NSAttributedString
if alreadyIncludesPrefix {
mentionString = displayName
} else {
let base = NSMutableAttributedString(attributedString: displayName)
let replacement = NSAttributedString(string: MessageBodyRanges.mentionPrefix)
if isRTL {
base.replaceCharacters(in: NSRange(location: 0, length: 0), with: replacement)
} else {
base.replaceCharacters(in: NSRange(location: base.length, length: 0), with: replacement)
}
mentionString = base
}
finalMentionLength = mentionString.length
mentionOffsetDelta = finalMentionLength - originalMentionRange.length
finalText.replaceCharacters(in: newMentionRange, with: mentionString)
}
rangeOffset += mentionOffsetDelta
// We have to adjust style ranges for the active style
if let style = styleAtCurrentIndex {
if style.originalRange.upperBound <= originalMentionRange.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
let finalStyle = (
NSRange(
location: style.newRange.location,
length: finalLength
),
style.style
)
finalStyles.append(finalStyle)
// 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 finalStyle = (
NSRange(
location: style.newRange.location,
length: finalText.length - style.newRange.location
),
style.style
)
finalStyles.append(finalStyle)
}
return MessageBodyRanges(
mentions: finalMentions,
styles: finalStyles
)
}
/// Sets the `.owsStyle` attribute on the string for any styles but
/// *does not* actually apply the styles. (e.g. no font attribute)
/// `.owsStyle` attributes can be converted to attributes just before display,
/// when the font and text color are available.
public func setStyleAttributesWithoutApplying(on string: NSMutableAttributedString) {
for (range, style) in styles {
var attrs: [NSAttributedString.Key: Any] = [
.owsStyle: style
]
if style.contains(.spoiler) {
SpoilerAttribute.fromOriginalRange(range).addToAttributes(&attrs)
}
// Desktop could send a range that extends past the end of the string;
// long term this should be corrected elsewhere but for now catch it here.
// TODO[TextFormatting]: correct for out of bounds style ranges in received protos.
guard let overlapRange = range.intersection(string.entireRange) else {
continue
}
string.addAttributes(attrs, range: overlapRange)
}
}
public struct SpoilerAttribute {
/// Externally: identifies a single spoiler range, even if the actual attribute has been
/// split when applied, as happens when a parallel attribute is applied to the middle
/// of a spoiler range.
///
/// Really this is just the original fill range of the spoiler, hashed. But that detail is
/// irrelevant to everthing outside of this class.
public let id: Int
public let effectiveRange: NSRange
private static let idKey = NSAttributedString.Key("OWSStyle.spoilerId")
fileprivate static func extractFromAttributes(
_ attrs: [NSAttributedString.Key: Any],
range: NSRange
) -> Self {
guard let id = attrs[Self.idKey] as? Int else {
return .fromOriginalRange(range)
}
return .init(id: id, effectiveRange: range)
}
fileprivate static func fromOriginalRange(_ range: NSRange) -> Self {
var hasher = Hasher()
hasher.combine(range)
let id = hasher.finalize()
return .init(id: id, effectiveRange: range)
}
private init(id: Int, effectiveRange: NSRange) {
self.id = id
self.effectiveRange = effectiveRange
}
fileprivate func addToAttributes(_ attrs: inout [NSAttributedString.Key: Any]) {
attrs[Self.idKey] = id
}
}
public enum SpoilerStyle {
case revealed
// TODO[TextFormatting]: instead of highlight, we should use
// a fancy animation which won't be represented in attributes.
case concealedWithHighlight(HighlightColors)
// TODO[TextFormatting]: add concealed with characters option
public struct HighlightColors {
public let baseColor: UIColor
// Subranges within the spoiler range to apply other
// colors to.
public let otherColors: [(NSRange, UIColor)]
public init(baseColor: UIColor, otherColors: [(NSRange, UIColor)]) {
self.baseColor = baseColor
self.otherColors = otherColors
}
}
}
/// Applies styles to the provided string (sets attributes for font, strikethrough, etc).
/// Font and colors for styles are based on the provided base font and color.
public func applyStyles(
to string: NSMutableAttributedString,
baseFont: UIFont,
textColor: UIColor,
spoilerStyler: (SpoilerAttribute) -> SpoilerStyle
) {
var spoilerCount = 0
for (range, style) in styles {
Self.applyStyle(
style: style,
to: string,
attributes: [:],
range: range,
baseFont: baseFont,
textColor: textColor,
spoilerStyler: spoilerStyler,
spoilerCount: &spoilerCount
)
}
}
/// Applies any `.owsStyle` attributes on the string (sets attributes for font, strikethrough, etc).
/// Font and colors for styles are based on the provided base font and color.
public static func applyStyleAttributes(
on string: NSMutableAttributedString,
baseFont: UIFont,
textColor: UIColor,
spoilerStyler: (SpoilerAttribute) -> SpoilerStyle
) {
let copy = NSAttributedString(attributedString: string)
var spoilerCount = 0
copy.enumerateAttributes(
in: string.entireRange,
using: { attrs, range, stop in
guard let style = attrs[.owsStyle] as? Style else {
return
}
applyStyle(
style: style,
to: string,
attributes: attrs,
range: range,
baseFont: baseFont,
textColor: textColor,
spoilerStyler: spoilerStyler,
spoilerCount: &spoilerCount
)
}
)
}
private static func applyStyle(
style: Style,
to string: NSMutableAttributedString,
attributes originalAttributes: [NSAttributedString.Key: Any],
range: NSRange,
baseFont: UIFont,
textColor: UIColor,
spoilerStyler: (SpoilerAttribute) -> SpoilerStyle,
spoilerCount: inout Int
) {
var fontTraits: UIFontDescriptor.SymbolicTraits = []
var attributes: [NSAttributedString.Key: Any] = [
.owsStyle: style
]
if style.contains(.bold) {
fontTraits.insert(.traitBold)
}
if style.contains(.italic) {
fontTraits.insert(.traitItalic)
}
if style.contains(.monospace) {
fontTraits.insert(.traitMonoSpace)
}
if style.contains(.strikethrough) {
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
attributes[.strikethroughColor] = textColor
}
var otherHighlightColors: [(NSRange, UIColor)]?
if style.contains(.spoiler) {
let spoilerAttribute = SpoilerAttribute.extractFromAttributes(
originalAttributes,
range: range
)
switch spoilerStyler(spoilerAttribute) {
case .revealed:
attributes[.foregroundColor] = textColor
spoilerAttribute.addToAttributes(&attributes)
case .concealedWithHighlight(let highlightColors):
attributes[.foregroundColor] = highlightColors.baseColor
attributes[.backgroundColor] = highlightColors.baseColor
spoilerAttribute.addToAttributes(&attributes)
otherHighlightColors = highlightColors.otherColors
}
spoilerCount += 1
}
if !fontTraits.isEmpty {
attributes[.font] = baseFont.withTraits(fontTraits)
}
string.addAttributes(attributes, range: range)
if let otherHighlightColors {
for (otherRange, otherColor) in otherHighlightColors {
let lowInside = otherRange.lowerBound >= range.lowerBound
&& otherRange.lowerBound < range.upperBound
let highInside = otherRange.upperBound <= range.upperBound
&& otherRange.upperBound > range.lowerBound
var otherAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: otherColor,
.backgroundColor: otherColor,
.owsStyle: style
]
SpoilerAttribute.extractFromAttributes(
originalAttributes,
range: range
).addToAttributes(&otherAttributes)
if lowInside && highInside {
string.addAttributes(
otherAttributes,
range: otherRange
)
} else if lowInside {
string.addAttributes(
otherAttributes,
range: NSRange(
location: otherRange.location,
length: range.upperBound - otherRange.lowerBound
)
)
} else if highInside {
string.addAttributes(
otherAttributes,
range: NSRange(
location: range.location,
length: otherRange.upperBound - range.lowerBound
)
)
}
}
}
}
public static func spoilerAttributes(in string: NSAttributedString) -> [SpoilerAttribute] {
var spoilerAttributes = [SpoilerAttribute]()
string.enumerateAttributes(
in: string.entireRange,
using: { attrs, range, _ in
guard
let style = attrs[.owsStyle] as? Style,
style.contains(.spoiler)
else {
return
}
spoilerAttributes.append(SpoilerAttribute.extractFromAttributes(attrs, range: range))
}
)
return spoilerAttributes
}
override public func isEqual(_ object: Any?) -> Bool {
guard let other = object as? MessageBodyRanges else {
return false
}
guard mentions == other.mentions else {
return false
}
guard styles.count == other.styles.count else {
return false
}
for i in 0..<styles.count {
let style = styles[i]
let otherStyle = other.styles[i]
guard style.0 == otherStyle.0 else {
return false
}
guard style.1 == otherStyle.1 else {
return false
}
}
return true
}
}
extension UIFont {
func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
// create a new font descriptor with the given traits
guard let fd = fontDescriptor.withSymbolicTraits(traits) else {
// the given traits couldn't be applied, return self
return self
}
// return a new font with the created font descriptor
return UIFont(descriptor: fd, size: pointSize)
}
}
extension NSAttributedString.Key {
public static let owsStyle = MessageBodyRanges.Style.attributedStringKey
}