mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			299 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			299 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
 | |
|     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
 | |
|     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
 | |
|     }
 | |
| }
 |