ChatBlobTextMessageCell.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. // _____ _
  2. // |_ _| |_ _ _ ___ ___ _ __ __ _
  3. // | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. // |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. //
  6. // Threema iOS Client
  7. // Copyright (c) 2020 Threema GmbH
  8. //
  9. // This program is free software: you can redistribute it and/or modify
  10. // it under the terms of the GNU Affero General Public License, version 3,
  11. // as published by the Free Software Foundation.
  12. //
  13. // This program is distributed in the hope that it will be useful,
  14. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. // GNU Affero General Public License for more details.
  17. //
  18. // You should have received a copy of the GNU Affero General Public License
  19. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. import Foundation
  21. @objc open class ChatBlobTextMessageCell: ChatBlobMessageCell, ZSWTappableLabelTapDelegate, ZSWTappableLabelLongPressDelegate {
  22. internal var _captionLabel: ZSWTappableLabel?
  23. open override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
  24. get {
  25. return getAccessibilityCustomActions()
  26. }
  27. set {
  28. super.accessibilityCustomActions = newValue
  29. }
  30. }
  31. private let canOpenPhoneLinks = UIApplication.shared.canOpenURL(URL(string: "tel:0")!)
  32. override public init!(style: UITableViewCell.CellStyle, reuseIdentifier: String!, transparent: Bool) {
  33. super.init(style: style, reuseIdentifier: reuseIdentifier, transparent: transparent)
  34. self.isAccessibilityElement = true
  35. }
  36. required public init?(coder: NSCoder) {
  37. fatalError("init(coder:) has not been implemented")
  38. }
  39. public func tappableLabel(_ tappableLabel: ZSWTappableLabel, tappedAt idx: Int, withAttributes attributes: [NSAttributedString.Key : Any] = [:]) {
  40. if let attribute = attributes[NSAttributedString.Key(rawValue: "NSTextCheckingResult")] {
  41. handleTapResult(result: attribute)
  42. }
  43. }
  44. public func tappableLabel(_ tappableLabel: ZSWTappableLabel, longPressedAt idx: Int, withAttributes attributes: [NSAttributedString.Key : Any] = [:]) {
  45. if let attribute = attributes[NSAttributedString.Key(rawValue: "NSTextCheckingResult")] {
  46. handleLongPressResult(result: attribute)
  47. }
  48. }
  49. open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
  50. if isEditing {
  51. return self
  52. }
  53. return super.hitTest(point, with: event)
  54. }
  55. open override func previewViewController(for previewingContext: UIViewControllerPreviewing!, viewControllerForLocation location: CGPoint) -> UIViewController! {
  56. guard let regionInfo = _captionLabel?.tappableRegionInfo(forPreviewingContext: previewingContext, location: location) else {
  57. return nil
  58. }
  59. let result = regionInfo.attributes[NSAttributedString.Key(rawValue: "NSTextCheckingResult")]
  60. if result.self is NSTextCheckingResult {
  61. let checkingResult = result as! NSTextCheckingResult
  62. if checkingResult.url != nil, checkingResult.resultType == .link && !checkingResult.url!.absoluteString.hasPrefix("mailto:") {
  63. let url = checkingResult.url
  64. if url?.scheme == "http" || url?.scheme == "https" {
  65. regionInfo.configure(previewingContext: previewingContext)
  66. let safari = ThreemaSafariViewController.init(url: url!)
  67. safari.url = url!
  68. return safari
  69. }
  70. }
  71. }
  72. return nil
  73. }
  74. /**
  75. Retun a menu if tapped object was a link.
  76. Will return nil if nothing was found
  77. */
  78. @available(iOS 13.0, *)
  79. open func contextMenuForLink(_ indexPath: IndexPath!, point: CGPoint) -> UIContextMenuConfiguration? {
  80. guard let convertedPoint = _captionLabel?.convert(point, from: chatVc.chatContent) else { return nil }
  81. if let regionInfo = _captionLabel?.checkIsPointAction(convertedPoint) {
  82. if let checkingResult = regionInfo[NSAttributedString.Key(rawValue: "NSTextCheckingResult")] as? NSTextCheckingResult {
  83. if checkingResult.url != nil, checkingResult.resultType == .link && !checkingResult.url!.absoluteString.hasPrefix("mailto:") {
  84. guard let url = checkingResult.url else {
  85. return nil
  86. }
  87. if url.scheme == "http" || url.scheme == "https" {
  88. let safariViewController = ThreemaSafariViewController.init(url: url)
  89. safariViewController.url = url
  90. return UIContextMenuConfiguration(identifier: indexPath as NSCopying?, previewProvider: { () -> UIViewController? in
  91. return safariViewController
  92. }) { (suggestedActions) -> UIMenu? in
  93. var menuItems = [UIAction]()
  94. let copyImage = UIImage.init(systemName: "doc.on.doc.fill", compatibleWith: self.traitCollection)
  95. let action = UIAction(title: BundleUtil.localizedString(forKey: "copy"), image: copyImage, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { (action) in
  96. UIPasteboard.general.string = self.displayString(for: url)
  97. }
  98. menuItems.append(action)
  99. return UIMenu(title: "", image: nil, identifier: .application, options: .displayInline, children: menuItems)
  100. }
  101. }
  102. }
  103. }
  104. }
  105. return nil
  106. }
  107. }
  108. extension ChatBlobTextMessageCell {
  109. // MARK: Private functions
  110. private func handleTapResult(result: Any) {
  111. if result.self is Contact {
  112. chatVc.mentionTapped(result)
  113. }
  114. else if result.self is NSString || result.self is String {
  115. let resultString = result as! String
  116. if resultString == "meContact" {
  117. chatVc.mentionTapped(resultString)
  118. }
  119. }
  120. else if result.self is NSTextCheckingResult {
  121. openLink(with: result as! NSTextCheckingResult)
  122. }
  123. }
  124. @objc private func openLink(with urlResult: NSTextCheckingResult) {
  125. if urlResult.resultType == .link {
  126. IDNSafetyHelper.safeOpen(url: urlResult.url!, viewController: self.chatVc)
  127. }
  128. else if urlResult.resultType == .phoneNumber {
  129. callPhoneNumber(phoneNumber: urlResult.phoneNumber!)
  130. }
  131. }
  132. private func callPhoneNumber(phoneNumber: String) {
  133. let cleanString = phoneNumber.replacingOccurrences(of: "\u{00a0}", with: "")
  134. if let url = URL.init(string: String(format: "tel:%@", cleanString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)) {
  135. UIApplication.shared.open(url, options: [:], completionHandler: nil)
  136. }
  137. }
  138. private func handleLongPressResult(result: Any) {
  139. if result.self is NSString || result.self is String {
  140. return
  141. }
  142. else if result.self is Contact {
  143. chatVc.mentionTapped(result)
  144. }
  145. else if result.self is NSTextCheckingResult {
  146. let checkingResult = result as! NSTextCheckingResult
  147. if checkingResult.resultType == .link {
  148. if let actionUrl = checkingResult.url {
  149. let actionSheet = NonFirstResponderActionSheet.init(title: displayString(for: actionUrl), message: nil, preferredStyle: .actionSheet)
  150. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "open"), style: .default, handler: { (action) in
  151. IDNSafetyHelper.safeOpen(url: actionUrl, viewController: self.chatVc)
  152. }))
  153. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "copy"), style: .default, handler: { (action) in
  154. UIPasteboard.general.string = self.displayString(for: actionUrl)
  155. }))
  156. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "cancel"), style: .cancel, handler: nil))
  157. if UIDevice.current.userInterfaceIdiom == .pad {
  158. actionSheet.popoverPresentationController?.sourceView = self
  159. actionSheet.popoverPresentationController?.sourceRect = self.bounds
  160. }
  161. chatVc.chatBar.resignFirstResponder()
  162. chatVc.present(actionSheet, animated: true, completion: nil)
  163. }
  164. }
  165. else if checkingResult.resultType == .phoneNumber {
  166. if let actionPhone = checkingResult.phoneNumber {
  167. let actionSheet = NonFirstResponderActionSheet.init(title: actionPhone, message: nil, preferredStyle: .actionSheet)
  168. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "call"), style: .default, handler: { (action) in
  169. self.callPhoneNumber(phoneNumber: actionPhone)
  170. }))
  171. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "copy"), style: .default, handler: { (action) in
  172. UIPasteboard.general.string = actionPhone
  173. }))
  174. actionSheet.addAction(UIAlertAction.init(title: BundleUtil.localizedString(forKey: "cancel"), style: .cancel, handler: nil))
  175. if UIDevice.current.userInterfaceIdiom == .pad {
  176. actionSheet.popoverPresentationController?.sourceView = self
  177. actionSheet.popoverPresentationController?.sourceRect = self.bounds
  178. }
  179. chatVc.chatBar.resignFirstResponder()
  180. chatVc.present(actionSheet, animated: true, completion: nil)
  181. }
  182. }
  183. }
  184. }
  185. private func displayString(for url: URL) -> String {
  186. return url.absoluteString.replacingOccurrences(of: "mailto:", with: "")
  187. }
  188. private func getAccessibilityCustomActions() -> [UIAccessibilityCustomAction] {
  189. if _captionLabel == nil {
  190. return []
  191. }
  192. if _captionLabel!.accessibilityElements == nil {
  193. return []
  194. }
  195. var actions = super.accessibilityCustomActions
  196. var indexCounter = 0
  197. if _captionLabel!.accessibilityElementCount() > 0 {
  198. for i in 0..._captionLabel!.accessibilityElementCount() - 1 {
  199. if let element = _captionLabel!.accessibilityElement(at: i) as? UIAccessibilityElement {
  200. if element.accessibilityLabel != nil, element.accessibilityLabel! != "." && element.accessibilityLabel! != "@" {
  201. if self.checkTextResult(text: element.accessibilityLabel!) != nil {
  202. let openString = "\(BundleUtil.localizedString(forKey: "open") ?? ""): \(element.accessibilityLabel!)"
  203. let linkAction = UIAccessibilityCustomAction.init(name: openString, target: self, selector: #selector(openLink(with:)))
  204. actions?.insert(linkAction, at: indexCounter)
  205. indexCounter += 1
  206. let shareString = "\(BundleUtil.localizedString(forKey: "share") ?? ""): \(element.accessibilityLabel!)"
  207. let shareAction = UIAccessibilityCustomAction.init(name: shareString, target: self, selector: #selector(shareLink))
  208. actions?.insert(shareAction, at: indexCounter)
  209. indexCounter += 1
  210. } else {
  211. let mentionString = "\(BundleUtil.localizedString(forKey: "details") ?? ""): \(element.accessibilityLabel!)"
  212. let mentionAction = UIAccessibilityCustomAction.init(name: mentionString, target: self, selector: #selector(openMentions(action:)))
  213. actions?.insert(mentionAction, at: indexCounter)
  214. indexCounter += 1
  215. }
  216. }
  217. }
  218. }
  219. }
  220. return actions!
  221. }
  222. @objc private func shareLink(action: UIAccessibilityCustomAction) -> Bool {
  223. let urlResult = checkTextResult(text: action.name)
  224. if urlResult?.resultType == .link {
  225. let activityViewController = ActivityUtil.activityViewController(withActivityItems: [urlResult!.url], applicationActivities: [])
  226. chatVc.present(activityViewController, animated: true, from: self)
  227. }
  228. else if urlResult?.resultType == .phoneNumber {
  229. let activityViewController = ActivityUtil.activityViewController(withActivityItems: [urlResult!.phoneNumber], applicationActivities: [])
  230. chatVc.present(activityViewController, animated: true, from: self)
  231. }
  232. return true
  233. }
  234. private func checkTextResult(text: String) -> NSTextCheckingResult? {
  235. var textCheckingTypes: NSTextCheckingTypes = NSTextCheckingResult.CheckingType.link.rawValue
  236. if canOpenPhoneLinks {
  237. textCheckingTypes |= NSTextCheckingResult.CheckingType.phoneNumber.rawValue
  238. }
  239. var urlResult: NSTextCheckingResult? = nil
  240. let detector = try! NSDataDetector(types: textCheckingTypes)
  241. detector.enumerateMatches(in: text, options: [], range: NSRange(location: 0, length: text.count)) { (result, flags, stop) in
  242. urlResult = result
  243. }
  244. return urlResult
  245. }
  246. @objc private func openMentions(action: UIAccessibilityCustomAction) -> Bool {
  247. let identity = action.name.replacingOccurrences(of: "\(BundleUtil.localizedString(forKey: "details")!) @", with: "")
  248. if identity == BundleUtil.localizedString(forKey: "me") {
  249. handleTapResult(result: "meContact")
  250. } else {
  251. if let contact = ContactStore.shared()?.contact(forIdentity: identity) {
  252. handleTapResult(result: contact)
  253. }
  254. }
  255. return true
  256. }
  257. }