mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
298 lines
11 KiB
Swift
298 lines
11 KiB
Swift
//
|
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
extension UnicodeScalar {
|
|
class EmojiRange {
|
|
// rangeStart and rangeEnd are inclusive.
|
|
let rangeStart: UInt32
|
|
let rangeEnd: UInt32
|
|
|
|
// MARK: Initializers
|
|
|
|
init(rangeStart: UInt32, rangeEnd: UInt32) {
|
|
self.rangeStart = rangeStart
|
|
self.rangeEnd = rangeEnd
|
|
}
|
|
}
|
|
|
|
// From:
|
|
// https://www.unicode.org/Public/emoji/
|
|
// Current Version:
|
|
// https://www.unicode.org/Public/emoji/6.0/emoji-data.txt
|
|
//
|
|
// These ranges can be code-generated using:
|
|
//
|
|
// * Scripts/emoji-data.txt
|
|
// * Scripts/emoji_ranges.py
|
|
static let kEmojiRanges = [
|
|
// NOTE: Don't treat Pound Sign # as Jumbomoji.
|
|
// EmojiRange(rangeStart:0x23, rangeEnd:0x23),
|
|
// NOTE: Don't treat Asterisk * as Jumbomoji.
|
|
// EmojiRange(rangeStart:0x2A, rangeEnd:0x2A),
|
|
// NOTE: Don't treat Digits 0..9 as Jumbomoji.
|
|
// EmojiRange(rangeStart:0x30, rangeEnd:0x39),
|
|
// NOTE: Don't treat Copyright Symbol © as Jumbomoji.
|
|
// EmojiRange(rangeStart:0xA9, rangeEnd:0xA9),
|
|
// NOTE: Don't treat Trademark Sign ® as Jumbomoji.
|
|
// EmojiRange(rangeStart:0xAE, rangeEnd:0xAE),
|
|
EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D),
|
|
EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C),
|
|
EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049),
|
|
EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF),
|
|
EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122),
|
|
EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139),
|
|
EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199),
|
|
EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA),
|
|
EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B),
|
|
EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328),
|
|
EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388),
|
|
EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF),
|
|
EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3),
|
|
EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA),
|
|
EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2),
|
|
EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB),
|
|
EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6),
|
|
EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0),
|
|
EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE),
|
|
EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF),
|
|
EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935),
|
|
EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07),
|
|
EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C),
|
|
EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50),
|
|
EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55),
|
|
EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030),
|
|
EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D),
|
|
EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297),
|
|
EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299),
|
|
EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F),
|
|
EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF),
|
|
EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F),
|
|
EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F),
|
|
EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171),
|
|
EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F),
|
|
EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E),
|
|
EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A),
|
|
EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF),
|
|
EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F),
|
|
EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A),
|
|
EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F),
|
|
EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A),
|
|
EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F),
|
|
EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F),
|
|
EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF),
|
|
EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F),
|
|
EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF),
|
|
EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F),
|
|
EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F),
|
|
EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F),
|
|
EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F),
|
|
EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD),
|
|
EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F)
|
|
]
|
|
|
|
var isEmoji: Bool {
|
|
|
|
// Binary search.
|
|
var left: Int = 0
|
|
var right = Int(UnicodeScalar.kEmojiRanges.count - 1)
|
|
while true {
|
|
let mid = (left + right) / 2
|
|
let midRange = UnicodeScalar.kEmojiRanges[mid]
|
|
if value < midRange.rangeStart {
|
|
if mid == left {
|
|
return false
|
|
}
|
|
right = mid - 1
|
|
} else if value > midRange.rangeEnd {
|
|
if mid == right {
|
|
return false
|
|
}
|
|
left = mid + 1
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
var isZeroWidthJoiner: Bool {
|
|
|
|
return value == 8205
|
|
}
|
|
}
|
|
|
|
extension String {
|
|
|
|
var glyphCount: Int {
|
|
let richText = NSAttributedString(string: self)
|
|
let line = CTLineCreateWithAttributedString(richText)
|
|
return CTLineGetGlyphCount(line)
|
|
}
|
|
|
|
var isSingleEmoji: Bool {
|
|
return glyphCount == 1 && containsEmoji
|
|
}
|
|
|
|
var containsEmoji: Bool {
|
|
return unicodeScalars.contains { $0.isEmoji }
|
|
}
|
|
|
|
var containsOnlyEmoji: Bool {
|
|
return !isEmpty
|
|
&& !unicodeScalars.contains(where: {
|
|
!$0.isEmoji
|
|
&& !$0.isZeroWidthJoiner
|
|
})
|
|
}
|
|
}
|
|
|
|
@objc public class DisplayableText: NSObject {
|
|
|
|
@objc public let fullText: String
|
|
@objc public let displayText: String
|
|
@objc public let isTextTruncated: Bool
|
|
@objc public let jumbomojiCount: UInt
|
|
|
|
@objc
|
|
public static let kMaxJumbomojiCount: UInt = 5
|
|
// This value is a bit arbitrary since we don't need to be 100% correct about
|
|
// rendering "Jumbomoji". It allows us to place an upper bound on worst-case
|
|
// performacne.
|
|
@objc
|
|
public static let kMaxCharactersPerEmojiCount: UInt = 10
|
|
|
|
// MARK: Initializers
|
|
|
|
@objc
|
|
public init(fullText: String, displayText: String, isTextTruncated: Bool) {
|
|
self.fullText = fullText
|
|
self.displayText = displayText
|
|
self.isTextTruncated = isTextTruncated
|
|
self.jumbomojiCount = DisplayableText.jumbomojiCount(in: fullText)
|
|
}
|
|
|
|
// MARK: Emoji
|
|
|
|
// If the string is...
|
|
//
|
|
// * Non-empty
|
|
// * Only contains 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 {
|
|
if string == "" {
|
|
return 0
|
|
}
|
|
if string.count > Int(kMaxJumbomojiCount * kMaxCharactersPerEmojiCount) {
|
|
return 0
|
|
}
|
|
guard string.containsOnlyEmoji else {
|
|
return 0
|
|
}
|
|
let emojiCount = string.glyphCount
|
|
if UInt(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)
|
|
}()
|
|
|
|
@objc
|
|
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 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
|
|
}
|
|
}
|
|
|
|
for match in linkDetector.matches(in: fullText, options: [], range: NSRange(location: 0, length: fullText.utf16.count)) {
|
|
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 = (fullText as NSString).substring(with: match.range)
|
|
guard isValidLink(linkText: rawTextOfMatch) else {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}()
|
|
|
|
// MARK: Filter Methods
|
|
|
|
@objc
|
|
public class func filterNotificationText(_ text: String?) -> String? {
|
|
guard let text = text?.filterStringForDisplay() else {
|
|
return nil
|
|
}
|
|
|
|
// iOS strips anything that looks like a printf formatting character from
|
|
// the notification body, so if we want to dispay a literal "%" in a notification
|
|
// it must be escaped.
|
|
// see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
|
|
// for more details.
|
|
return text.replacingOccurrences(of: "%", with: "%%")
|
|
}
|
|
|
|
@objc
|
|
public class func displayableText(_ rawText: String) -> DisplayableText {
|
|
// Only show up to N characters of text.
|
|
let kMaxTextDisplayLength = 512
|
|
let fullText = rawText.filterStringForDisplay()
|
|
var isTextTruncated = false
|
|
var displayText = fullText
|
|
if displayText.count > kMaxTextDisplayLength {
|
|
// Trim whitespace before _AND_ after slicing the snipper from the string.
|
|
let snippet = String(displayText.prefix(kMaxTextDisplayLength)).ows_stripped()
|
|
displayText = String(format: NSLocalizedString("OVERSIZE_TEXT_DISPLAY_FORMAT", comment:
|
|
"A display format for oversize text messages."),
|
|
snippet)
|
|
isTextTruncated = true
|
|
}
|
|
|
|
let displayableText = DisplayableText(fullText: fullText, displayText: displayText, isTextTruncated: isTextTruncated)
|
|
return displayableText
|
|
}
|
|
}
|