// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import LibSignalClient public import SignalServiceKit public class CVTextLabel: NSObject { // MARK: - public struct MentionItem: Equatable { public let mentionAci: Aci public let range: NSRange public init(mentionAci: Aci, range: NSRange) { self.mentionAci = mentionAci self.range = range } } // MARK: - public struct ReferencedUserItem: Equatable { public let address: SignalServiceAddress public let range: NSRange public init(address: SignalServiceAddress, range: NSRange) { self.address = address self.range = range } } // MARK: - public struct UnrevealedSpoilerItem: Equatable { public let spoilerId: Int public let interactionUniqueId: String public let interactionIdentifier: InteractionSnapshotIdentifier public let range: NSRange public init( spoilerId: Int, interactionUniqueId: String, interactionIdentifier: InteractionSnapshotIdentifier, range: NSRange, ) { self.spoilerId = spoilerId self.interactionUniqueId = interactionUniqueId self.interactionIdentifier = interactionIdentifier self.range = range } } // MARK: - public struct DeleteAuthorItem: Equatable { public let deleteAuthorAci: Aci public let range: NSRange public init(deleteAuthorAci: Aci, range: NSRange) { self.deleteAuthorAci = deleteAuthorAci self.range = range } } // MARK: - public enum Item: Equatable, CustomStringConvertible { case dataItem(dataItem: TextCheckingDataItem) case mention(mentionItem: MentionItem) case referencedUser(referencedUserItem: ReferencedUserItem) case unrevealedSpoiler(UnrevealedSpoilerItem) case deleteAuthor(deleteAuthorItem: DeleteAuthorItem) public var range: NSRange { switch self { case .dataItem(let dataItem): return dataItem.range case .mention(let mentionItem): return mentionItem.range case .referencedUser(let referencedUserItem): return referencedUserItem.range case .unrevealedSpoiler(let item): return item.range case .deleteAuthor(let deleteAuthorItem): return deleteAuthorItem.range } } public var description: String { switch self { case .dataItem: return ".dataItem" case .mention: return ".mention" case .referencedUser: return ".referencedUser" case .unrevealedSpoiler: return ".unrevealedSpoiler" case .deleteAuthor: return ".deleteAuthor" } } } public enum LinkifyStyle { case linkAttribute case underlined(bodyTextColor: UIColor) } // MARK: - public struct Config { public let text: CVTextValue public let displayConfig: HydratedMessageBody.DisplayConfiguration public let font: UIFont public let textColor: UIColor public let selectionStyling: [NSAttributedString.Key: Any] public let textAlignment: NSTextAlignment public let lineBreakMode: NSLineBreakMode public let numberOfLines: Int public let cacheKey: String public let items: [Item] public let linkifyStyle: CVTextLabel.LinkifyStyle public init( text: CVTextValue, displayConfig: HydratedMessageBody.DisplayConfiguration, font: UIFont, textColor: UIColor, selectionStyling: [NSAttributedString.Key: Any], textAlignment: NSTextAlignment, lineBreakMode: NSLineBreakMode, numberOfLines: Int = 0, cacheKey: String? = nil, items: [Item], linkifyStyle: CVTextLabel.LinkifyStyle, ) { self.text = text self.displayConfig = displayConfig self.font = font self.textColor = textColor self.selectionStyling = selectionStyling self.textAlignment = textAlignment self.lineBreakMode = lineBreakMode self.numberOfLines = numberOfLines if let cacheKey { self.cacheKey = cacheKey } else { self.cacheKey = "\(text.cacheKey),\(displayConfig.sizingCacheKey),\(font.fontName),\(font.pointSize),\(numberOfLines),\(lineBreakMode.rawValue),\(textAlignment.rawValue)" } self.items = items self.linkifyStyle = linkifyStyle } } // MARK: - private let label = Label() public var view: UIView { label } override public init() { label.backgroundColor = .clear label.isOpaque = false super.init() } public func configureForRendering(config: Config, spoilerAnimationManager: SpoilerAnimationManager) { AssertIsOnMainThread() label.config = config label.spoilerAnimationManager = spoilerAnimationManager spoilerAnimationManager.prepareViewForRendering(view) } public func setIsCellVisible(_ isCellVisible: Bool) { label.setIsCellVisible(isCellVisible) } public func reset() { label.config = nil label.reset() } public class Measurement: CVMeasurementObject { public let size: CGSize public let lastLineRect: CGRect? init(size: CGSize, lastLineRect: CGRect?) { self.size = size self.lastLineRect = lastLineRect } static let empty = { Measurement(size: .zero, lastLineRect: nil) }() // MARK: - Equatable public static func ==(lhs: Measurement, rhs: Measurement) -> Bool { lhs.size == rhs.size && lhs.lastLineRect == rhs.lastLineRect } } public static func measureSize(config: Config, maxWidth: CGFloat) -> Measurement { guard !config.text.isEmpty else { return .empty } let attributedString = Label.formatAttributedString(config: config) let layoutManager = NSLayoutManager() let textContainer = NSTextContainer(size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude)) layoutManager.addTextContainer(textContainer) textContainer.lineFragmentPadding = 0 textContainer.lineBreakMode = config.lineBreakMode textContainer.maximumNumberOfLines = config.numberOfLines // The string must be assigned to the NSTextStorage *after* it has // an associated layout manager. Otherwise, the `NSOriginalFont` // attribute will not be defined correctly resulting in incorrect // measurement of character sets that font doesn't support natively // (CJK, Arabic, Emoji, etc.) let textStorage = NSTextStorage() textStorage.addLayoutManager(layoutManager) textStorage.setAttributedString(attributedString) // The NSTextStorage object owns all the other layout components, // so there are only weak references to it. In optimized builds, // this can result in it being freed before we perform measurement. // We can work around this by explicitly extending the lifetime of // textStorage until measurement is completed. return withExtendedLifetime(textStorage) { let glyphRange = layoutManager.glyphRange(for: textContainer) var lastLineRect: CGRect? if glyphRange.location != NSNotFound, glyphRange.length > 0 { let lastGlyphIndex = glyphRange.length - 1 lastLineRect = layoutManager.lineFragmentUsedRect( forGlyphAt: lastGlyphIndex, effectiveRange: nil, withoutAdditionalLayout: true, ) } let size = layoutManager.usedRect(for: textContainer).size.ceil return Measurement(size: size, lastLineRect: lastLineRect) } } // MARK: - Gestures public func itemForGesture(sender: UIGestureRecognizer) -> Item? { label.itemForGesture(sender: sender) } public func animate(selectedItem: Item) { label.animate(selectedItem: selectedItem) } // MARK: - Linkification public static func linkifyData( attributedText: NSMutableAttributedString, linkifyStyle: LinkifyStyle, items: [CVTextLabel.Item], ) { // Sort so that we can detect overlap. let items = items.sorted { $0.range.location < $1.range.location } for item in items { let range = item.range switch item { case .mention, .referencedUser, .unrevealedSpoiler, .deleteAuthor: // Do nothing; these are already styled. continue case .dataItem(let dataItem): guard let link = dataItem.url.absoluteString.nilIfEmpty else { owsFailDebug("Could not build data link.") continue } switch linkifyStyle { case .linkAttribute: attributedText.addAttribute(.link, value: link, range: range) case .underlined(let bodyTextColor): attributedText.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range) attributedText.addAttribute(.underlineColor, value: bodyTextColor, range: range) } } } } // MARK: - fileprivate class Label: UIView { fileprivate var config: Config? { didSet { reset() apply(config: config) } } fileprivate var spoilerAnimationManager: SpoilerAnimationManager? { didSet { if spoilerAnimationManager == nil, let oldValue, self.isAnimatingSpoilers { self.isAnimatingSpoilers = false oldValue.removeViewAnimator(self) } else { updateSpoilerAnimationState() } } } private lazy var textStorage = NSTextStorage() private lazy var layoutManager = NSLayoutManager() private lazy var textContainer = NSTextContainer() private var animationTimer: Timer? // MARK: - override init(frame: CGRect) { AssertIsOnMainThread() super.init(frame: frame) textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) isUserInteractionEnabled = true addInteraction(UIDragInteraction(delegate: self)) contentMode = .redraw } @available(*, unavailable, message: "Unimplemented") required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } override var frame: CGRect { didSet { // Ensure the text container size is kept in sync; // this is used to compute spoiler positions. textContainer.size = bounds.size if oldValue != frame, isAnimatingSpoilers, let spoilerAnimationManager { spoilerAnimationManager.didUpdateAnimationState(for: self) } } } private var isCellVisible = false fileprivate func setIsCellVisible(_ isCellVisible: Bool) { self.isCellVisible = isCellVisible updateSpoilerAnimationState() } fileprivate func reset() { AssertIsOnMainThread() animationTimer?.invalidate() animationTimer = nil updateSpoilerAnimationState() } private func apply(config: Config?) { AssertIsOnMainThread() guard let config else { reset() return } updateTextStorage(config: config) } override open func draw(_ rect: CGRect) { super.draw(rect) textContainer.size = bounds.size let glyphRange = layoutManager.glyphRange(for: textContainer) layoutManager.drawBackground(forGlyphRange: glyphRange, at: .zero) layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: .zero) } // MARK: - fileprivate func updateTextStorage(config: Config) { AssertIsOnMainThread() textContainer.lineFragmentPadding = 0 textContainer.lineBreakMode = config.lineBreakMode textContainer.maximumNumberOfLines = config.numberOfLines textContainer.size = bounds.size guard !config.text.isEmpty else { reset() textStorage.setAttributedString(NSAttributedString()) setNeedsDisplay() return } let attributedString = Self.formatAttributedString(config: config) textStorage.setAttributedString(attributedString) setNeedsDisplay() updateSpoilerAnimationState() } fileprivate static func formatAttributedString(config: Config) -> NSMutableAttributedString { let attributedString: NSMutableAttributedString switch config.text { case .text(let text): attributedString = NSMutableAttributedString(string: text) config.displayConfig.searchRanges?.apply( attributedString, isDarkThemeEnabled: Theme.isDarkThemeEnabled, ) case .attributedText(let attributedText): attributedString = NSMutableAttributedString(attributedString: attributedText) config.displayConfig.searchRanges?.apply( attributedString, isDarkThemeEnabled: Theme.isDarkThemeEnabled, ) case .messageBody(let messageBody): // This will internally apply search ranges, no need to handle separately. let attributedText = messageBody.asAttributedStringForDisplay( config: config.displayConfig, isDarkThemeEnabled: Theme.isDarkThemeEnabled, ) attributedString = (attributedText as? NSMutableAttributedString) ?? NSMutableAttributedString(attributedString: attributedText) } // The original attributed string may not have an overall font assigned. // Without it, measurement will not be correct. We assign the default font // to any ranges that don't currently have a font assigned. attributedString.addDefaultAttributeToEntireString(.font, value: config.font) // Set a default text color based on the passed in config attributedString.addDefaultAttributeToEntireString(.foregroundColor, value: config.textColor) CVTextLabel.linkifyData( attributedText: attributedString, linkifyStyle: config.linkifyStyle, items: config.items, ) var range = NSRange(location: 0, length: 0) var attributes = attributedString.attributes(at: 0, effectiveRange: &range) let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() paragraphStyle.lineBreakMode = config.lineBreakMode paragraphStyle.alignment = config.textAlignment attributes[.paragraphStyle] = paragraphStyle attributedString.setAttributes(attributes, range: range) return attributedString } fileprivate func updateAttributesForSelection(selectedItem: Item? = nil) { AssertIsOnMainThread() guard let config else { reset() return } guard let selectedItem else { apply(config: config) return } switch selectedItem { case .mention, .referencedUser, .dataItem: textStorage.addAttributes(config.selectionStyling, range: selectedItem.range) case .unrevealedSpoiler: // Don't apply anything for spoilers. return case .deleteAuthor: // Don't apply anything for delete author return } setNeedsDisplay() } fileprivate func item(at location: CGPoint) -> Item? { AssertIsOnMainThread() guard let config = self.config else { return nil } guard textStorage.length > 0 else { return nil } guard let characterIndex = textContainer.characterIndex( of: location, textStorage: textStorage, layoutManager: layoutManager, ) else { return nil } for item in config.items { if item.range.contains(characterIndex) { return item } } return nil } // MARK: - Animation func animate(selectedItem: Item) { AssertIsOnMainThread() updateAttributesForSelection(selectedItem: selectedItem) self.animationTimer?.invalidate() self.animationTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in self?.updateAttributesForSelection() } } // MARK: Spoiler private var isAnimatingSpoilers = false private func updateSpoilerAnimationState() { let wantsToAnimate: Bool if isCellVisible, let config { switch config.text { case .text, .attributedText: wantsToAnimate = false case .messageBody(let body): wantsToAnimate = body.hasSpoilerRangesToAnimate } } else { wantsToAnimate = false } guard let spoilerAnimationManager else { return } guard isAnimatingSpoilers != wantsToAnimate else { if isAnimatingSpoilers { spoilerAnimationManager.didUpdateAnimationState(for: self) } return } if wantsToAnimate { spoilerAnimationManager.addViewAnimator(self) } else { spoilerAnimationManager.removeViewAnimator(self) } self.isAnimatingSpoilers = wantsToAnimate } // MARK: - Gestures func itemForGesture(sender: UIGestureRecognizer) -> Item? { AssertIsOnMainThread() let location = sender.location(in: self) guard let selectedItem = item(at: location) else { return nil } return selectedItem } // MARK: - override func updateConstraints() { super.updateConstraints() deactivateAllConstraints() } } } // MARK: - extension CVTextLabel.Label: SpoilerableViewAnimator { var spoilerableView: UIView? { return self } func spoilerFrames() -> [SpoilerFrame] { guard let config else { return [] } switch config.text { case .text, .attributedText: return [] case .messageBody(let messageBody): return Self.spoilerFrames( messageBody: messageBody, displayConfig: config.displayConfig, textContainer: textContainer, textStorage: textStorage, layoutManager: layoutManager, bounds: self.bounds.size, ) } } var spoilerFramesCacheKey: Int { var hasher = Hasher() hasher.combine("CVTextLabel.Label") hasher.combine(config?.text) config?.displayConfig.hashForSpoilerFrames(into: &hasher) // Order matters. 100x10 is not the same hash value as 10x100. hasher.combine(textContainer.size.width) hasher.combine(textContainer.size.height) return hasher.finalize() } // Every input here should be represented in the cache key above. private static func spoilerFrames( messageBody: HydratedMessageBody, displayConfig: HydratedMessageBody.DisplayConfiguration, textContainer: NSTextContainer, textStorage: NSTextStorage, layoutManager: NSLayoutManager, bounds: CGSize, ) -> [SpoilerFrame] { let spoilerRanges = messageBody.spoilerRangesForAnimation(config: displayConfig) return textContainer.boundingRects( ofCharacterRanges: spoilerRanges, rangeMap: \.range, textStorage: textStorage, layoutManager: layoutManager, transform: { rect, spoilerRange in return .init( frame: rect, color: spoilerRange.color, style: spoilerRange.isSearchResult ? .highlight : .standard, ) }, ) } } // MARK: - extension CVTextLabel.Label: UIDragInteractionDelegate { public func dragInteraction( _ interaction: UIDragInteraction, itemsForBeginning session: UIDragSession, ) -> [UIDragItem] { guard nil != self.config else { owsFailDebug("Missing config.") return [] } let location = session.location(in: self) guard let selectedItem = self.item(at: location) else { return [] } switch selectedItem { case .mention: // We don't let users drag mentions yet. return [] case .referencedUser: // Dragging is not applicable to referenced users return [] case .unrevealedSpoiler: // Dragging is not applicable for spoilers. return [] case .deleteAuthor: // Dragging is not applicable for admin delete author. return [] case .dataItem(let dataItem): animate(selectedItem: selectedItem) let itemProvider = NSItemProvider(object: dataItem.snippet as NSString) let dragItem = UIDragItem(itemProvider: itemProvider) let glyphRange = self.layoutManager.glyphRange( forCharacterRange: selectedItem.range, actualCharacterRange: nil, ) var textLineRects = [NSValue]() self.layoutManager.enumerateEnclosingRects( forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange( location: NSNotFound, length: 0, ), in: self.textContainer, ) { rect, _ in textLineRects.append(NSValue(cgRect: rect)) } let previewParameters = UIDragPreviewParameters(textLineRects: textLineRects) let preview = UIDragPreview(view: self, parameters: previewParameters) dragItem.previewProvider = { preview } return [dragItem] } } }