// // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import SignalServiceKit public import SignalUI public class DisplayableText: NSObject { private struct Content { let textValue: CVTextValue let naturalAlignment: NSTextAlignment } private let _fullContent: AtomicValue private var fullContent: Content { get { _fullContent.get() } set { _fullContent.set(newValue) } } private let _truncatedContent = AtomicOptional(nil, lock: .sharedGlobal) private var truncatedContent: Content? { get { _truncatedContent.get() } set { _truncatedContent.set(newValue) } } public var fullTextValue: CVTextValue { fullContent.textValue } public var truncatedTextValue: CVTextValue? { truncatedContent?.textValue } public var displayTextValue: CVTextValue { return truncatedContent?.textValue ?? fullContent.textValue } public func textValue(isTextExpanded: Bool) -> CVTextValue { if isTextExpanded { return fullTextValue } else { return displayTextValue } } public var fullTextNaturalAlignment: NSTextAlignment { return fullContent.naturalAlignment } @objc public var displayTextNaturalAlignment: NSTextAlignment { return truncatedContent?.naturalAlignment ?? fullContent.naturalAlignment } public var isTextTruncated: Bool { return truncatedContent != nil } private static let maxInlineRenderingSizeEstimate = 1024 * 8 public var canRenderTruncatedTextInline: Bool { return isTextTruncated && renderingSizeEstimate <= Self.maxInlineRenderingSizeEstimate } public let renderingSizeEstimate: Int public let jumbomojiCount: UInt public static let kMaxJumbomojiCount: Int = 5 static let truncatedTextSuffix: String = "…" // MARK: Initializers private init(fullContent: Content, truncatedContent: Content?) { self._fullContent = AtomicValue(fullContent, lock: .sharedGlobal) self._truncatedContent.set(truncatedContent) switch fullContent.textValue { case .text(let text): self.jumbomojiCount = DisplayableText.jumbomojiCount(in: text) self.renderingSizeEstimate = DisplayableText.renderingSizeEstimate(of: text) case .attributedText(let attributedText): self.jumbomojiCount = DisplayableText.jumbomojiCount(in: attributedText.string) self.renderingSizeEstimate = DisplayableText.renderingSizeEstimate(of: attributedText.string) case .messageBody(let messageBody): self.jumbomojiCount = messageBody.jumbomojiCount(DisplayableText.jumbomojiCount(in:)) self.renderingSizeEstimate = messageBody.renderingSizeEstimate(DisplayableText.renderingSizeEstimate(of:)) } super.init() } #if TESTABLE_BUILD static func testOnlyInit(fullContent: CVTextValue, truncatedContent: CVTextValue?) -> DisplayableText { return DisplayableText( fullContent: .init(textValue: fullContent, naturalAlignment: fullContent.naturalTextAligment), truncatedContent: truncatedContent.map { .init(textValue: $0, naturalAlignment: $0.naturalTextAligment) }, ) } #endif // MARK: Emoji // If the string... // // * Is not empty // * Contains only emoji // * Contains <= kMaxJumbomojiCount emoji // // ...return the number of emoji (to be treated as "Jumbomoji") in the string. private class func jumbomojiCount(in string: String) -> UInt { guard string.containsOnlyEmojiIgnoringWhitespace else { return 0 } let emojiCount = string.removeCharacters(characterSet: .whitespacesAndNewlines).count if emojiCount > kMaxJumbomojiCount { return 0 } return UInt(emojiCount) } // For perf we use a static linkDetector. It doesn't change and building DataDetectors is // surprisingly expensive. This should be fine, since NSDataDetector is an NSRegularExpression // and NSRegularExpressions are thread safe. private static let linkDetector: NSDataDetector? = { return try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() private static let hostRegex: NSRegularExpression? = { let pattern = "^(?:https?:\\/\\/)?([^:\\/\\s]+)(.*)?$" return try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) }() public lazy var shouldAllowLinkification: Bool = { guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else { owsFailDebug("linkDetector was unexpectedly nil") return false } func isValidLink(linkText: String) -> Bool { guard LinkValidator.isValidLink(linkText: linkText) else { return false } guard let hostRegex = DisplayableText.hostRegex else { owsFailDebug("hostRegex was unexpectedly nil") return false } guard let hostText = hostRegex.parseFirstMatch(inText: linkText) else { owsFailDebug("hostText was unexpectedly nil") return false } let strippedHost = hostText.replacingOccurrences(of: ".", with: "") if strippedHost.isOnlyASCII() { return true } else if strippedHost.hasAnyASCII() { // mix of ascii and non-ascii is invalid return false } else { // IDN return true } } func validate(_ rawText: String) -> Bool { guard LinkValidator.canParseURLs(in: rawText) else { return false } for match in linkDetector.matches(in: rawText, options: [], range: rawText.entireRange) { guard let matchURL: URL = match.url 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 = (rawText as NSString).substring(with: match.range) guard isValidLink(linkText: rawTextOfMatch) else { return false } } return true } switch fullContent.textValue { case .text(let text): return validate(text) case .attributedText(let attributedText): return validate(attributedText.string) case .messageBody(let messageBody): return messageBody.shouldAllowLinkification(linkDetector: linkDetector, isValidLink: isValidLink(linkText:)) } }() // MARK: Filter Methods private static let newLineScalar = 16 private class func renderingSizeEstimate(of text: String) -> Int { let newlineByte = UInt8(ascii: "\n") return text.utf8.lazy.map { $0 == newlineByte ? newLineScalar : 1 }.reduce(0, +) } public class var empty: DisplayableText { return DisplayableText( fullContent: .init(textValue: .text(""), naturalAlignment: .natural), truncatedContent: nil, ) } private static func limitNewLines(in text: String, to maxNewLines: Int) -> String { var textSuffix = text[...] var remainingCount = maxNewLines while remainingCount > 0 { guard let newLineRange = textSuffix.range(of: "\n") else { return text } textSuffix = textSuffix[newLineRange.upperBound...] remainingCount -= 1 } guard let newLineRange = textSuffix.range(of: "\n") else { return text } return String(text[.. DisplayableText { let textValue: CVTextValue if messageBody.ranges.hasRanges { let hydrated = messageBody .hydrating( mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: transaction), ) textValue = .messageBody(hydrated) } else { textValue = .text(messageBody.text) } let fullContent = Content( textValue: textValue, naturalAlignment: textValue.naturalTextAligment, ) // Only show up to N characters of text. let kMaxTextDisplayLength = 512 let kMaxSnippetNewLines = 15 func truncatePlaintext(_ text: String) -> String? { guard let truncatedText = text.trimmedIfNeeded(maxGlyphCount: kMaxTextDisplayLength) else { return nil } // Message bubbles by default should be short. We don't ever // want to show more than X new lines in the truncated text. return limitNewLines(in: truncatedText, to: kMaxSnippetNewLines) } let truncatedContent = { () -> Content? in switch textValue { case .text(let text): guard var truncatedText = truncatePlaintext(text) else { return nil } truncatedText = truncatedText.ows_stripped() + Self.truncatedTextSuffix return Content( textValue: .text(truncatedText), naturalAlignment: truncatedText.naturalTextAlignment, ) case .attributedText(let attributedText): guard let truncatedPlaintext = truncatePlaintext(attributedText.string) else { return nil } let truncatedAttributedText = ( attributedText.attributedSubstring(from: truncatedPlaintext.entireRange).ows_stripped() + Self.truncatedTextSuffix, ) return Content( textValue: .attributedText(truncatedAttributedText), naturalAlignment: truncatedAttributedText.string.naturalTextAlignment, ) case .messageBody(let messageBody): guard let truncatedBody = messageBody.truncatingIfNeeded( maxGlyphCount: kMaxTextDisplayLength, truncationSuffix: Self.truncatedTextSuffix, ) else { return nil } return Content( textValue: .messageBody(truncatedBody), naturalAlignment: truncatedBody.naturalTextAlignment, ) } }() return DisplayableText(fullContent: fullContent, truncatedContent: truncatedContent) } }