DisplayableText.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. //
  2. // Copyright (c) 2019 Open Whisper Systems. All rights reserved.
  3. //
  4. import Foundation
  5. extension UnicodeScalar {
  6. class EmojiRange {
  7. // rangeStart and rangeEnd are inclusive.
  8. let rangeStart: UInt32
  9. let rangeEnd: UInt32
  10. // MARK: Initializers
  11. init(rangeStart: UInt32, rangeEnd: UInt32) {
  12. self.rangeStart = rangeStart
  13. self.rangeEnd = rangeEnd
  14. }
  15. }
  16. // From:
  17. // https://www.unicode.org/Public/emoji/
  18. // Current Version:
  19. // https://www.unicode.org/Public/emoji/6.0/emoji-data.txt
  20. //
  21. // These ranges can be code-generated using:
  22. //
  23. // * Scripts/emoji-data.txt
  24. // * Scripts/emoji_ranges.py
  25. static let kEmojiRanges = [
  26. // NOTE: Don't treat Pound Sign # as Jumbomoji.
  27. // EmojiRange(rangeStart:0x23, rangeEnd:0x23),
  28. // NOTE: Don't treat Asterisk * as Jumbomoji.
  29. // EmojiRange(rangeStart:0x2A, rangeEnd:0x2A),
  30. // NOTE: Don't treat Digits 0..9 as Jumbomoji.
  31. // EmojiRange(rangeStart:0x30, rangeEnd:0x39),
  32. // NOTE: Don't treat Copyright Symbol © as Jumbomoji.
  33. // EmojiRange(rangeStart:0xA9, rangeEnd:0xA9),
  34. // NOTE: Don't treat Trademark Sign ® as Jumbomoji.
  35. // EmojiRange(rangeStart:0xAE, rangeEnd:0xAE),
  36. EmojiRange(rangeStart: 0x200D, rangeEnd: 0x200D),
  37. EmojiRange(rangeStart: 0x203C, rangeEnd: 0x203C),
  38. EmojiRange(rangeStart: 0x2049, rangeEnd: 0x2049),
  39. EmojiRange(rangeStart: 0x20D0, rangeEnd: 0x20FF),
  40. EmojiRange(rangeStart: 0x2122, rangeEnd: 0x2122),
  41. EmojiRange(rangeStart: 0x2139, rangeEnd: 0x2139),
  42. EmojiRange(rangeStart: 0x2194, rangeEnd: 0x2199),
  43. EmojiRange(rangeStart: 0x21A9, rangeEnd: 0x21AA),
  44. EmojiRange(rangeStart: 0x231A, rangeEnd: 0x231B),
  45. EmojiRange(rangeStart: 0x2328, rangeEnd: 0x2328),
  46. EmojiRange(rangeStart: 0x2388, rangeEnd: 0x2388),
  47. EmojiRange(rangeStart: 0x23CF, rangeEnd: 0x23CF),
  48. EmojiRange(rangeStart: 0x23E9, rangeEnd: 0x23F3),
  49. EmojiRange(rangeStart: 0x23F8, rangeEnd: 0x23FA),
  50. EmojiRange(rangeStart: 0x24C2, rangeEnd: 0x24C2),
  51. EmojiRange(rangeStart: 0x25AA, rangeEnd: 0x25AB),
  52. EmojiRange(rangeStart: 0x25B6, rangeEnd: 0x25B6),
  53. EmojiRange(rangeStart: 0x25C0, rangeEnd: 0x25C0),
  54. EmojiRange(rangeStart: 0x25FB, rangeEnd: 0x25FE),
  55. EmojiRange(rangeStart: 0x2600, rangeEnd: 0x27BF),
  56. EmojiRange(rangeStart: 0x2934, rangeEnd: 0x2935),
  57. EmojiRange(rangeStart: 0x2B05, rangeEnd: 0x2B07),
  58. EmojiRange(rangeStart: 0x2B1B, rangeEnd: 0x2B1C),
  59. EmojiRange(rangeStart: 0x2B50, rangeEnd: 0x2B50),
  60. EmojiRange(rangeStart: 0x2B55, rangeEnd: 0x2B55),
  61. EmojiRange(rangeStart: 0x3030, rangeEnd: 0x3030),
  62. EmojiRange(rangeStart: 0x303D, rangeEnd: 0x303D),
  63. EmojiRange(rangeStart: 0x3297, rangeEnd: 0x3297),
  64. EmojiRange(rangeStart: 0x3299, rangeEnd: 0x3299),
  65. EmojiRange(rangeStart: 0xFE00, rangeEnd: 0xFE0F),
  66. EmojiRange(rangeStart: 0x1F000, rangeEnd: 0x1F0FF),
  67. EmojiRange(rangeStart: 0x1F10D, rangeEnd: 0x1F10F),
  68. EmojiRange(rangeStart: 0x1F12F, rangeEnd: 0x1F12F),
  69. EmojiRange(rangeStart: 0x1F16C, rangeEnd: 0x1F171),
  70. EmojiRange(rangeStart: 0x1F17E, rangeEnd: 0x1F17F),
  71. EmojiRange(rangeStart: 0x1F18E, rangeEnd: 0x1F18E),
  72. EmojiRange(rangeStart: 0x1F191, rangeEnd: 0x1F19A),
  73. EmojiRange(rangeStart: 0x1F1AD, rangeEnd: 0x1F1FF),
  74. EmojiRange(rangeStart: 0x1F201, rangeEnd: 0x1F20F),
  75. EmojiRange(rangeStart: 0x1F21A, rangeEnd: 0x1F21A),
  76. EmojiRange(rangeStart: 0x1F22F, rangeEnd: 0x1F22F),
  77. EmojiRange(rangeStart: 0x1F232, rangeEnd: 0x1F23A),
  78. EmojiRange(rangeStart: 0x1F23C, rangeEnd: 0x1F23F),
  79. EmojiRange(rangeStart: 0x1F249, rangeEnd: 0x1F64F),
  80. EmojiRange(rangeStart: 0x1F680, rangeEnd: 0x1F6FF),
  81. EmojiRange(rangeStart: 0x1F774, rangeEnd: 0x1F77F),
  82. EmojiRange(rangeStart: 0x1F7D5, rangeEnd: 0x1F7FF),
  83. EmojiRange(rangeStart: 0x1F80C, rangeEnd: 0x1F80F),
  84. EmojiRange(rangeStart: 0x1F848, rangeEnd: 0x1F84F),
  85. EmojiRange(rangeStart: 0x1F85A, rangeEnd: 0x1F85F),
  86. EmojiRange(rangeStart: 0x1F888, rangeEnd: 0x1F88F),
  87. EmojiRange(rangeStart: 0x1F8AE, rangeEnd: 0x1FFFD),
  88. EmojiRange(rangeStart: 0xE0020, rangeEnd: 0xE007F)
  89. ]
  90. var isEmoji: Bool {
  91. // Binary search.
  92. var left: Int = 0
  93. var right = Int(UnicodeScalar.kEmojiRanges.count - 1)
  94. while true {
  95. let mid = (left + right) / 2
  96. let midRange = UnicodeScalar.kEmojiRanges[mid]
  97. if value < midRange.rangeStart {
  98. if mid == left {
  99. return false
  100. }
  101. right = mid - 1
  102. } else if value > midRange.rangeEnd {
  103. if mid == right {
  104. return false
  105. }
  106. left = mid + 1
  107. } else {
  108. return true
  109. }
  110. }
  111. }
  112. var isZeroWidthJoiner: Bool {
  113. return value == 8205
  114. }
  115. }
  116. extension String {
  117. var glyphCount: Int {
  118. let richText = NSAttributedString(string: self)
  119. let line = CTLineCreateWithAttributedString(richText)
  120. return CTLineGetGlyphCount(line)
  121. }
  122. var isSingleEmoji: Bool {
  123. return glyphCount == 1 && containsEmoji
  124. }
  125. var containsEmoji: Bool {
  126. return unicodeScalars.contains { $0.isEmoji }
  127. }
  128. var containsOnlyEmoji: Bool {
  129. return !isEmpty
  130. && !unicodeScalars.contains(where: {
  131. !$0.isEmoji
  132. && !$0.isZeroWidthJoiner
  133. })
  134. }
  135. }
  136. @objc public class DisplayableText: NSObject {
  137. @objc public let fullText: String
  138. @objc public let displayText: String
  139. @objc public let isTextTruncated: Bool
  140. @objc public let jumbomojiCount: UInt
  141. @objc
  142. static let kMaxJumbomojiCount: UInt = 5
  143. // This value is a bit arbitrary since we don't need to be 100% correct about
  144. // rendering "Jumbomoji". It allows us to place an upper bound on worst-case
  145. // performacne.
  146. @objc
  147. static let kMaxCharactersPerEmojiCount: UInt = 10
  148. // MARK: Initializers
  149. @objc
  150. public init(fullText: String, displayText: String, isTextTruncated: Bool) {
  151. self.fullText = fullText
  152. self.displayText = displayText
  153. self.isTextTruncated = isTextTruncated
  154. self.jumbomojiCount = DisplayableText.jumbomojiCount(in: fullText)
  155. }
  156. // MARK: Emoji
  157. // If the string is...
  158. //
  159. // * Non-empty
  160. // * Only contains emoji
  161. // * Contains <= kMaxJumbomojiCount emoji
  162. //
  163. // ...return the number of emoji (to be treated as "Jumbomoji") in the string.
  164. private class func jumbomojiCount(in string: String) -> UInt {
  165. if string == "" {
  166. return 0
  167. }
  168. if string.count > Int(kMaxJumbomojiCount * kMaxCharactersPerEmojiCount) {
  169. return 0
  170. }
  171. guard string.containsOnlyEmoji else {
  172. return 0
  173. }
  174. let emojiCount = string.glyphCount
  175. if UInt(emojiCount) > kMaxJumbomojiCount {
  176. return 0
  177. }
  178. return UInt(emojiCount)
  179. }
  180. // For perf we use a static linkDetector. It doesn't change and building DataDetectors is
  181. // surprisingly expensive. This should be fine, since NSDataDetector is an NSRegularExpression
  182. // and NSRegularExpressions are thread safe.
  183. private static let linkDetector: NSDataDetector? = {
  184. return try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
  185. }()
  186. private static let hostRegex: NSRegularExpression? = {
  187. let pattern = "^(?:https?:\\/\\/)?([^:\\/\\s]+)(.*)?$"
  188. return try? NSRegularExpression(pattern: pattern)
  189. }()
  190. @objc
  191. public lazy var shouldAllowLinkification: Bool = {
  192. guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else {
  193. owsFailDebug("linkDetector was unexpectedly nil")
  194. return false
  195. }
  196. func isValidLink(linkText: String) -> Bool {
  197. guard let hostRegex = DisplayableText.hostRegex else {
  198. owsFailDebug("hostRegex was unexpectedly nil")
  199. return false
  200. }
  201. guard let hostText = hostRegex.parseFirstMatch(inText: linkText) else {
  202. owsFailDebug("hostText was unexpectedly nil")
  203. return false
  204. }
  205. let strippedHost = hostText.replacingOccurrences(of: ".", with: "") as NSString
  206. if strippedHost.isOnlyASCII {
  207. return true
  208. } else if strippedHost.hasAnyASCII {
  209. // mix of ascii and non-ascii is invalid
  210. return false
  211. } else {
  212. // IDN
  213. return true
  214. }
  215. }
  216. for match in linkDetector.matches(in: fullText, options: [], range: NSRange(location: 0, length: fullText.utf16.count)) {
  217. guard let matchURL: URL = match.url else {
  218. continue
  219. }
  220. // We extract the exact text from the `fullText` rather than use match.url.host
  221. // because match.url.host actually escapes non-ascii domains into puny-code.
  222. //
  223. // But what we really want is to check the text which will ultimately be presented to
  224. // the user.
  225. let rawTextOfMatch = (fullText as NSString).substring(with: match.range)
  226. guard isValidLink(linkText: rawTextOfMatch) else {
  227. return false
  228. }
  229. }
  230. return true
  231. }()
  232. // MARK: Filter Methods
  233. @objc
  234. public class func filterNotificationText(_ text: String?) -> String? {
  235. guard let text = text?.filterStringForDisplay() else {
  236. return nil
  237. }
  238. // iOS strips anything that looks like a printf formatting character from
  239. // the notification body, so if we want to dispay a literal "%" in a notification
  240. // it must be escaped.
  241. // see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
  242. // for more details.
  243. return text.replacingOccurrences(of: "%", with: "%%")
  244. }
  245. @objc
  246. public class func displayableText(_ rawText: String) -> DisplayableText {
  247. // Only show up to N characters of text.
  248. let kMaxTextDisplayLength = 512
  249. let fullText = rawText.filterStringForDisplay()
  250. var isTextTruncated = false
  251. var displayText = fullText
  252. if displayText.count > kMaxTextDisplayLength {
  253. // Trim whitespace before _AND_ after slicing the snipper from the string.
  254. let snippet = String(displayText.prefix(kMaxTextDisplayLength)).ows_stripped()
  255. displayText = String(format: NSLocalizedString("OVERSIZE_TEXT_DISPLAY_FORMAT", comment:
  256. "A display format for oversize text messages."),
  257. snippet)
  258. isTextTruncated = true
  259. }
  260. let displayableText = DisplayableText(fullText: fullText, displayText: displayText, isTextTruncated: isTextTruncated)
  261. return displayableText
  262. }
  263. }