Signal-iOS/Signal/src/util/DisplayableText.swift
Max Radermacher 184a576e58
Clean up jumbomojiCount & add tests
In e41335a7b5, `String.glyphCount` was updated to match
`String.count`, which eliminates the need to do the pre-emptive “are
there less than 50 characters” check. Since that commit, both of the
character count checks in this method were calling the same underlying
API, so there’s no need to call it twice, and we can just check it
against the strict upper bound immediately.
2022-06-10 14:33:46 -07:00

345 lines
12 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class DisplayableText: NSObject {
private struct Content {
let textValue: CVTextValue
let naturalAlignment: NSTextAlignment
var stringValue: String { textValue.stringValue }
}
private let _fullContent: AtomicValue<Content>
private var fullContent: Content {
get { _fullContent.get() }
set { _fullContent.set(newValue) }
}
private let _truncatedContent = AtomicOptional<Content>(nil)
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
}
}
@objc
public var fullAttributedText: NSAttributedString {
switch fullContent.textValue {
case .text(let text):
return NSAttributedString(string: text)
case .attributedText(let attributedText):
return attributedText
}
}
@objc
public var fullTextNaturalAlignment: NSTextAlignment {
return fullContent.naturalAlignment
}
@objc
public var displayAttributedText: NSAttributedString {
let content = truncatedContent ?? fullContent
switch content.textValue {
case .text(let text):
return NSAttributedString(string: text)
case .attributedText(let attributedText):
return attributedText
}
}
@objc
public var displayTextNaturalAlignment: NSTextAlignment {
return truncatedContent?.naturalAlignment ?? fullContent.naturalAlignment
}
@objc
public var isTextTruncated: Bool {
return truncatedContent != nil
}
private static let maxInlineText = 1024 * 8
@objc
public var canRenderTruncatedTextInline: Bool {
return isTextTruncated && fullLengthWithNewLineScalar <= Self.maxInlineText
}
@objc
public let fullLengthWithNewLineScalar: Int
@objc
public let jumbomojiCount: UInt
@objc
public static let kMaxJumbomojiCount: Int = 5
static let truncatedTextSuffix: String = ""
// MARK: Initializers
private init(fullContent: Content, truncatedContent: Content?) {
self._fullContent = AtomicValue(fullContent)
self._truncatedContent.set(truncatedContent)
self.jumbomojiCount = DisplayableText.jumbomojiCount(in: fullContent.stringValue)
self.fullLengthWithNewLineScalar = DisplayableText.fullLengthWithNewLineScalar(in: fullContent.stringValue)
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(themeDidChange),
name: .ThemeDidChange,
object: nil
)
}
@objc
private func themeDidChange() {
// When the theme changes, we must refresh any mention attributes.
func updateContent(_ content: Content) -> Content {
switch content.textValue {
case .text:
// We only need to update attributedText.
return content
case .attributedText(let attributedText):
let mutableFullText = NSMutableAttributedString(attributedString: attributedText)
Mention.refreshAttributes(in: mutableFullText)
return Content(textValue: .attributedText(attributedText: mutableFullText),
naturalAlignment: content.naturalAlignment)
}
}
// When the theme changes, we must refresh any mention attributes.
fullContent = updateContent(fullContent)
if let truncatedContent = truncatedContent {
self.truncatedContent = updateContent(truncatedContent)
}
}
// MARK: Emoji
// If the string...
//
// * Contains <= kMaxJumbomojiCount characters
// * Contains only emoji
//
// ...return the number of emoji (to be treated as "Jumbomoji") in the string.
private class func jumbomojiCount(in string: String) -> UInt {
// Don't iterate the entire string; only inspect the first few characters.
let stringPrefix = string.prefix(kMaxJumbomojiCount + 1)
let emojiCount = stringPrefix.count
guard emojiCount <= kMaxJumbomojiCount else {
return 0
}
guard string.containsOnlyEmoji else {
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])
}()
@objc
public lazy var shouldAllowLinkification: Bool = {
guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else {
owsFailDebug("linkDetector was unexpectedly nil")
return false
}
func isProblematicCodepoint(_ scalar: UnicodeScalar) -> Bool {
switch scalar {
case "\u{202C}", // POP DIRECTIONAL FORMATTING
"\u{202D}", // LEFT-TO-RIGHT OVERRIDE
"\u{202E}": // RIGHT-TO-LEFT OVERRIDE
return true
case "\u{2500}"..."\u{25FF}": // Box Drawing, Block Elements, Geometric Shapes
return true
default:
return false
}
}
func isValidLink(linkText: String) -> Bool {
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: "") as NSString
if strippedHost.isOnlyASCII {
return true
} else if strippedHost.hasAnyASCII {
// mix of ascii and non-ascii is invalid
return false
} else {
// IDN
return true
}
}
let rawText = fullContent.stringValue
if rawText.unicodeScalars.contains(where: isProblematicCodepoint) {
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
}()
// MARK: Filter Methods
private static let newLineRegex = try! NSRegularExpression(pattern: "\n", options: [])
private static let newLineScalar = 16
private class func fullLengthWithNewLineScalar(in string: String) -> Int {
let numberOfNewLines = newLineRegex.numberOfMatches(
in: string,
options: [],
range: string.entireRange
)
return string.utf16.count + numberOfNewLines * newLineScalar
}
@objc
public class var empty: DisplayableText {
return DisplayableText(
fullContent: .init(textValue: .text(text: ""), naturalAlignment: .natural),
truncatedContent: nil
)
}
@objc
public class func displayableTextForTests(_ text: String) -> DisplayableText {
return DisplayableText(
fullContent: .init(textValue: .text(text: text),
naturalAlignment: text.naturalTextAlignment),
truncatedContent: nil
)
}
@objc
public class func displayableText(withMessageBody messageBody: MessageBody, mentionStyle: Mention.Style, transaction: SDSAnyReadTransaction) -> DisplayableText {
let textValue = messageBody.textValue(style: mentionStyle,
attributes: [:],
shouldResolveAddress: { _ in true }, // Resolve all mentions in messages.
transaction: transaction.unwrapGrdbRead)
let fullContent = Content(
textValue: textValue,
naturalAlignment: textValue.stringValue.naturalTextAlignment
)
// Only show up to N characters of text.
let kMaxTextDisplayLength = 512
let kMaxSnippetNewLines = 15
let truncatedContent: Content?
if fullContent.stringValue.count > kMaxTextDisplayLength {
var snippetLength = kMaxTextDisplayLength
// Message bubbles by default should be short. We don't ever
// want to show more than X new lines in the truncated text.
let newLineMatches = newLineRegex.matches(
in: fullContent.stringValue,
options: [],
range: NSRange(location: 0, length: kMaxTextDisplayLength)
)
if newLineMatches.count > kMaxSnippetNewLines {
snippetLength = newLineMatches[kMaxSnippetNewLines - 1].range.location
}
switch textValue {
case .text(let text):
let truncatedText = (text.substring(to: snippetLength)
.ows_stripped()
+ Self.truncatedTextSuffix)
truncatedContent = Content(textValue: .text(text: truncatedText),
naturalAlignment: truncatedText.naturalTextAlignment)
case .attributedText(let attributedText):
var mentionRange = NSRange()
let possibleOverlappingMention = attributedText.attribute(
.mention,
at: snippetLength,
longestEffectiveRange: &mentionRange,
in: attributedText.entireRange
)
// There's a mention overlapping our normal truncate point, we want to truncate sooner
// so we don't "split" the mention.
if possibleOverlappingMention != nil && mentionRange.location < snippetLength {
snippetLength = mentionRange.location
}
// Trim whitespace before _AND_ after slicing the snipper from the string.
let truncatedAttributedText = attributedText
.attributedSubstring(from: NSRange(location: 0, length: snippetLength))
.ows_stripped()
.stringByAppendingString(Self.truncatedTextSuffix)
truncatedContent = Content(textValue: .attributedText(attributedText: truncatedAttributedText),
naturalAlignment: truncatedAttributedText.string.naturalTextAlignment)
}
} else {
truncatedContent = nil
}
return DisplayableText(fullContent: fullContent, truncatedContent: truncatedContent)
}
}