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.
345 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|