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

186 lines
6.8 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
// Note that this struct gets put into NSAttributedString,
// so we want it to mostly contain simple types and not
// hold references to other objects, as a string holding
// a reference to the outside world is very likely to cause
// surprises.
public struct StyleDisplayConfiguration: Equatable {
public let baseFont: UIFont
public let textColor: ThemedColor
public let spoilerAnimationColorOverride: ThemedColor?
public let revealedSpoilerBgColor: ThemedColor?
public let revealAllIds: Bool
public let revealedIds: Set<StyleIdType>
/// If true, unrevealed spoiler text will be invisible (clear).
/// If false, unrevealed spoiler text will use `textColor` as its background color.
public let useAnimatedSpoilers: Bool
public var spoilerColor: ThemedColor {
if useAnimatedSpoilers, let spoilerAnimationColorOverride {
return spoilerAnimationColorOverride
} else {
return textColor
}
}
public init(
baseFont: UIFont,
textColor: ThemedColor,
spoilerAnimationColorOverride: ThemedColor? = nil,
revealedSpoilerBgColor: ThemedColor? = nil,
revealAllIds: Bool,
revealedIds: Set<StyleIdType>,
useAnimatedSpoilers: Bool
) {
self.baseFont = baseFont
self.textColor = textColor
self.spoilerAnimationColorOverride = spoilerAnimationColorOverride
self.revealedSpoilerBgColor = revealedSpoilerBgColor
self.revealAllIds = revealAllIds
self.revealedIds = revealedIds
self.useAnimatedSpoilers = useAnimatedSpoilers
}
public func hashForSpoilerFrames(into hasher: inout Hasher) {
hasher.combine(textColor)
hasher.combine(spoilerAnimationColorOverride)
hasher.combine(revealAllIds)
hasher.combine(revealedIds)
}
}
internal struct StyleAttribute: Equatable, Hashable {
typealias Style = MessageBodyRanges.Style
typealias SingleStyle = MessageBodyRanges.SingleStyle
typealias CollapsedStyle = MessageBodyRanges.CollapsedStyle
/// Externally: identifies a single style range, even if the actual attribute has been
/// split when applied, as happens when a parallel attribute is applied to the middle
/// of a style range.
///
/// Really this is just the original full range of the style, hashed. But that detail is
/// irrelevant to everthing outside of this class.
internal let ids: [SingleStyle: StyleIdType]
internal let style: Style
internal static func fromCollapsedStyle(_ style: CollapsedStyle) -> Self {
return .init(ids: style.originals.mapValues(\.id), style: style.style)
}
private init(ids: [SingleStyle: StyleIdType], style: Style) {
self.ids = ids
self.style = style
}
internal func applyAttributes(
to string: NSMutableAttributedString,
at range: NSRange,
config: StyleDisplayConfiguration,
searchRanges: HydratedMessageBody.DisplayConfiguration.SearchRanges?,
isDarkThemeEnabled: Bool
) {
var fontTraits: UIFontDescriptor.SymbolicTraits = []
var attributes: [NSAttributedString.Key: Any] = [:]
if style.contains(.bold) {
fontTraits.insert(.traitBold)
}
if style.contains(.italic) {
fontTraits.insert(.traitItalic)
}
if style.contains(.monospace) {
fontTraits.insert(.traitMonoSpace)
}
var isSpoilerRevealed: Bool?
if style.contains(.spoiler), let spoilerId = self.ids[.spoiler] {
isSpoilerRevealed = config.revealAllIds || config.revealedIds.contains(spoilerId)
if !isSpoilerRevealed! {
attributes[.foregroundColor] = UIColor.clear
if config.useAnimatedSpoilers {
attributes[.backgroundColor] = UIColor.clear
} else {
attributes[.backgroundColor] = config.spoilerColor.color(isDarkThemeEnabled: isDarkThemeEnabled)
}
} else if let revealedSpoilerBgColor = config.revealedSpoilerBgColor {
attributes[.foregroundColor] = config.textColor.color(isDarkThemeEnabled: isDarkThemeEnabled)
attributes[.backgroundColor] = revealedSpoilerBgColor.color(isDarkThemeEnabled: isDarkThemeEnabled)
}
}
if style.contains(.strikethrough) && (isSpoilerRevealed ?? true) {
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
attributes[.strikethroughColor] = config.textColor.color(isDarkThemeEnabled: isDarkThemeEnabled)
}
if !fontTraits.isEmpty {
attributes[.font] = config.baseFont.withTraits(fontTraits)
}
string.addAttributes(attributes, range: range)
// if we had a spoiler range, apply and search ranges to override
// spoiler attributes we applied above.
if style.contains(.spoiler), !(isSpoilerRevealed ?? false), let searchRanges {
for searchMatchRange in searchRanges.matchedRanges {
guard
let intersection = searchMatchRange.intersection(range),
intersection.length > 0,
intersection.location != NSNotFound
else {
continue
}
let backgroundColor: UIColor
if config.useAnimatedSpoilers {
backgroundColor = .clear
} else {
backgroundColor = searchRanges.matchingBackgroundColor.color(isDarkThemeEnabled: isDarkThemeEnabled)
}
string.addAttributes(
[
.backgroundColor: backgroundColor,
.foregroundColor: UIColor.clear
],
range: intersection
)
}
}
}
private static let plaintextSpoilerCharacter = ""
private static let maxPlaintextSpoilerLength = 4
internal func applyPlaintextSpoiler(
to string: NSMutableString,
at range: NSRange
) {
string.replaceCharacters(
in: range,
with: String(
repeating: Self.plaintextSpoilerCharacter,
count: min(range.length, Self.maxPlaintextSpoilerLength)
)
)
}
}
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)
}
}