OEMentionsHelper.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. // _____ _
  2. // |_ _| |_ _ _ ___ ___ _ __ __ _
  3. // | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. // |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. //
  6. // Threema iOS Client
  7. // Copyright (c) 2019-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 public protocol OEMentionsHelperDelegate: class {
  22. func mentionSelected(id:Int, name:String)
  23. func textView(_ growingTextView: HPGrowingTextView!, willChangeHeight height: Float)
  24. func textView(_ growingTextView: HPGrowingTextView!, shouldChangeTextIn range: NSRange, replacementText text: String!) -> Bool
  25. func textViewDidChange(_ growingTextView: HPGrowingTextView!)
  26. }
  27. @objc public class OEMentionsHelper: NSObject {
  28. @objc open weak var delegate: OEMentionsHelperDelegate?
  29. private var oementions: OEMentions
  30. private var growingTextView: HPGrowingTextView
  31. private var topLine: UIView
  32. private var mainView: UIView
  33. private let regex: String = "@\\[[0-9A-Z*@]{8}\\]"
  34. private var mentionCountBeforeChange: Int = 0
  35. private var shouldUpdateTextColor: Bool = false
  36. private var isDictationRunning: Bool = false
  37. @objc required public init(containerView: UIView, chatInputView: HPGrowingTextView, mainView: UIView, sortedContacts: [Contact]) {
  38. let memberlist = OEMentionsHelper.buildOeObjectsList(sortedContacts: sortedContacts)
  39. growingTextView = chatInputView
  40. self.mainView = mainView
  41. oementions = OEMentions.init(containerView: containerView, textView: chatInputView.internalTextView, mainView: mainView, oeObjects: memberlist)
  42. topLine = UIView.init()
  43. topLine.frame.size.height = 1
  44. topLine.backgroundColor = Colors.hairline()
  45. topLine.isHidden = true
  46. mainView.insertSubview(topLine, aboveSubview: oementions.tableView)
  47. super.init()
  48. oementions.delegate = self
  49. chatInputView.delegate = self
  50. oementions.nameFont = chatInputView.internalTextView.font!
  51. setupColors()
  52. oementions.showMentionFullInContainer = false
  53. oementions.tableView.addObserver(self, forKeyPath: "hidden", options: [.new], context: nil)
  54. }
  55. @objc public func setupColors() {
  56. oementions.nameColor = Colors.fontLight()
  57. oementions.notMentionColor = growingTextView.internalTextView.textColor!
  58. oementions.changeMentionTableviewBackground(color: Colors.background())
  59. oementions.changeMentionTableviewSeparatorColor(color: Colors.hairline())
  60. updateTextColor()
  61. }
  62. @objc public func formattedMentionText() -> String {
  63. var textViewText = growingTextView.internalTextView.text
  64. var difference = 0
  65. if oementions.mentionsIndexes.count != 0 {
  66. let mentionsIndexes = oementions.mentionsIndexes.sorted(by: { $0.0 < $1.0 })
  67. for (index,dict) in mentionsIndexes {
  68. let length = dict["length"] as! Int
  69. let key = dict["key"] as! String
  70. let nsRange = NSMakeRange(index + difference, length)
  71. let range = Range.init(nsRange, in: textViewText!)
  72. textViewText?.replaceSubrange(range!, with: key)
  73. let nameCount = length
  74. let replaceCount = key.count
  75. difference = difference + (replaceCount - nameCount)
  76. }
  77. }
  78. return textViewText!
  79. }
  80. @objc public func addMentions(draft: String) {
  81. var draftString = draft
  82. do {
  83. let mentionRegex = try NSRegularExpression.init(pattern: regex, options: .caseInsensitive)
  84. var finished = false
  85. var lastNotFoundIndex = -1
  86. while !finished {
  87. let mentionResult = mentionRegex.matches(in: draftString, options: .reportCompletion, range: NSRange.init(location: 0, length: draftString.utf16.count))
  88. var result: NSTextCheckingResult? = nil
  89. if lastNotFoundIndex == -1 {
  90. result = mentionResult.first
  91. } else {
  92. if mentionResult.count >= lastNotFoundIndex + 2 {
  93. result = mentionResult[lastNotFoundIndex + 1]
  94. }
  95. }
  96. if result == nil {
  97. finished = true
  98. break
  99. }
  100. let mentionTag = String(draftString[String.Index(utf16Offset: result!.range.location, in: draftString)...String.Index(utf16Offset: result!.range.location + result!.range.length - 1, in: draftString)])
  101. if mentionTag.count == 11 {
  102. let identity = String(mentionTag[String.Index(utf16Offset: 2, in: mentionTag)...String.Index(utf16Offset: 9, in: mentionTag)]).uppercased()
  103. let contact = ContactStore.shared().contact(forIdentity: identity)
  104. if contact != nil || identity == MyIdentityStore.shared()?.identity || identity == "@@@@@@@@" {
  105. var displayName = BundleUtil.localizedString(forKey: "me")
  106. if let nickname = MyIdentityStore.shared().pushFromName {
  107. if nickname.utf16.count > 0 {
  108. displayName = nickname
  109. }
  110. }
  111. if contact != nil {
  112. displayName = contact!.mentionName
  113. }
  114. else if identity == "@@@@@@@@" {
  115. displayName = BundleUtil.localizedString(forKey: "mentions_all")
  116. }
  117. let range = Range.init(result!.range, in: draftString)
  118. draftString = draftString.replacingCharacters(in: range!, with: "@\(displayName!)")
  119. let dict = ["key": mentionTag, "length": displayName!.utf16.count + 1] as [String : Any]
  120. oementions.mentionsIndexes[result!.range.location] = dict
  121. } else {
  122. let range = Range.init(result!.range, in: draftString)
  123. draftString = draftString.replacingCharacters(in: range!, with: "@\(identity)")
  124. if lastNotFoundIndex == -1 {
  125. lastNotFoundIndex = 0
  126. } else {
  127. lastNotFoundIndex += 1
  128. }
  129. }
  130. } else {
  131. if lastNotFoundIndex == -1 {
  132. lastNotFoundIndex = 0
  133. } else {
  134. lastNotFoundIndex += 1
  135. }
  136. }
  137. }
  138. growingTextView.text = draftString
  139. updateTextColor()
  140. }
  141. catch {
  142. print("failed regex draft for mentions")
  143. }
  144. }
  145. @objc public func resetMentionsIndexes() {
  146. oementions.mentionsIndexes.removeAll()
  147. }
  148. @objc public func updateContainterViewFrame() {
  149. // add space on top of input view
  150. oementions.textViewHeight = growingTextView.frame.size.height + 3.0
  151. oementions.updatePosition()
  152. topLine.frame = CGRect.init(x: oementions.tableView.frame.origin.x, y: oementions.tableView.frame.origin.y - 1, width: oementions.tableView.frame.size.width, height: 1)
  153. }
  154. @objc public func updateTextColor() {
  155. if !isDictationRunning {
  156. var attributes = [NSAttributedString.Key: AnyObject]()
  157. attributes[.foregroundColor] = oementions.notMentionColor
  158. attributes[.font] = oementions.nameFont
  159. if oementions.mentionsIndexes.count != 0 {
  160. let attributedString: NSMutableAttributedString = NSMutableAttributedString.init(string: oementions.textView!.text, attributes: attributes)
  161. let mentionsIndexes = oementions.mentionsIndexes.sorted(by: { $0.0 < $1.0 })
  162. for (index,dict) in mentionsIndexes {
  163. let length = dict["length"] as! Int
  164. attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: oementions.nameColor, range: NSMakeRange(index, length))
  165. attributedString.addAttribute(NSAttributedString.Key.font, value: oementions.nameFont, range: NSMakeRange(index, length))
  166. attributedString.addAttribute(NSAttributedString.Key.foregroundColor, value: Colors.fontLink()!, range: NSMakeRange(index, length))
  167. }
  168. if let selectedRange = oementions.textView!.selectedTextRange {
  169. oementions.textView!.attributedText = attributedString
  170. // and only if the new position is valid
  171. if let newPosition = oementions.textView!.position(from: selectedRange.start, in: UITextLayoutDirection.left, offset: 0) {
  172. // set the new position
  173. oementions.textView!.selectedTextRange = oementions.textView!.textRange(from: newPosition, to: newPosition)
  174. }
  175. } else {
  176. oementions.textView!.attributedText = attributedString
  177. }
  178. } else {
  179. if let selectedRange = oementions.textView!.selectedTextRange {
  180. oementions.textView!.attributedText = NSMutableAttributedString.init(string: oementions.textView!.text, attributes: attributes)
  181. // and only if the new position is valid
  182. if let newPosition = oementions.textView!.position(from: selectedRange.start, in: UITextLayoutDirection.left, offset: 0) {
  183. // set the new position
  184. oementions.textView!.selectedTextRange = oementions.textView!.textRange(from: newPosition, to: newPosition)
  185. }
  186. } else {
  187. oementions.textView!.attributedText = NSMutableAttributedString.init(string: oementions.textView!.text, attributes: attributes)
  188. }
  189. }
  190. }
  191. }
  192. @objc public func updateOeObjects(sortedContacts: [Contact]) {
  193. oementions.setOeObjects(oeObjects: OEMentionsHelper.buildOeObjectsList(sortedContacts: sortedContacts))
  194. }
  195. class func buildOeObjectsList(sortedContacts: [Contact]) -> [OEObject] {
  196. var memberlist = [OEObject]()
  197. // add @all contact
  198. let oeObject = OEObject.init(id: 0, name: "@" + BundleUtil.localizedString(forKey: "all"), key: "@[@@@@@@@@]", object: nil)
  199. memberlist.append(oeObject)
  200. var i = 1
  201. for contact in sortedContacts {
  202. let oeObject = OEObject.init(id: i, name: "@" + contact.displayName, key: "@[\(contact.identity!)]", object: contact)
  203. memberlist.append(oeObject)
  204. i += 1
  205. }
  206. return memberlist
  207. }
  208. override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
  209. let tableView = object as! UITableView
  210. topLine.isHidden = tableView.isHidden
  211. }
  212. private func startKeyboardObserver() {
  213. NotificationCenter.default.addObserver(self, selector: #selector(changeInputMode), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
  214. }
  215. private func stopKeyboardObserver() {
  216. NotificationCenter.default.removeObserver(self)
  217. }
  218. @objc func changeInputMode(notification: NSNotification) {
  219. let inputMethod = growingTextView.textInputMode?.primaryLanguage
  220. if inputMethod == "dictation" {
  221. isDictationRunning = true
  222. } else {
  223. isDictationRunning = false
  224. }
  225. }
  226. }
  227. extension OEMentionsHelper: HPGrowingTextViewDelegate {
  228. public func growingTextViewDidBeginEditing(_ growingTextView: HPGrowingTextView!) {
  229. startKeyboardObserver()
  230. }
  231. public func growingTextView(_ growingTextView: HPGrowingTextView!, willChangeHeight height: Float) {
  232. delegate?.textView(growingTextView, willChangeHeight: height)
  233. }
  234. public func growingTextView(_ growingTextView: HPGrowingTextView!, didChangeHeight height: Float) {
  235. updateContainterViewFrame()
  236. }
  237. public func growingTextView(_ growingTextView: HPGrowingTextView!, shouldChangeTextIn range: NSRange, replacementText text: String!) -> Bool {
  238. mentionCountBeforeChange = oementions.mentionsIndexes.count
  239. _ = oementions.textView(growingTextView.internalTextView, shouldChangeTextIn: range, replacementText: text)
  240. return delegate?.textView(growingTextView, shouldChangeTextIn: range, replacementText: text) ?? true
  241. }
  242. public func growingTextViewDidChange(_ growingTextView: HPGrowingTextView!) {
  243. if growingTextView.internalTextView.isFirstResponder {
  244. oementions.updatePosition()
  245. }
  246. if shouldUpdateTextColor == true || (mentionCountBeforeChange > oementions.mentionsIndexes.count && oementions.mentionsIndexes.count == 0) {
  247. shouldUpdateTextColor = false
  248. updateTextColor()
  249. }
  250. delegate?.textViewDidChange(growingTextView)
  251. }
  252. public func growingTextViewDidEndEditing(_ growingTextView: HPGrowingTextView!) {
  253. stopKeyboardObserver()
  254. oementions.textViewDidEndEditing(growingTextView.internalTextView)
  255. }
  256. }
  257. extension OEMentionsHelper: OEMentionsDelegate {
  258. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath, oeObject: OEObject) -> UITableViewCell {
  259. let cell:MentionCell = MentionCell.init(style: UITableViewCell.CellStyle.default, reuseIdentifier: "MentionCell")
  260. cell.backgroundColor = UIColor.clear
  261. cell.selectionStyle = UITableViewCell.SelectionStyle.none
  262. if let contact = oeObject.object as? Contact {
  263. cell.contact = contact
  264. } else {
  265. // @all contact
  266. cell.allContact = true
  267. }
  268. return cell
  269. }
  270. func mentionSelected(id: Int, name: String) {
  271. delegate?.mentionSelected(id: id, name: name)
  272. growingTextView.refreshHeight()
  273. updateTextColor()
  274. }
  275. func tableViewPositionUpdated() {
  276. if !oementions.tableView.isHidden {
  277. topLine.frame = CGRect.init(x: oementions.tableView.frame.origin.x, y: oementions.tableView.frame.origin.y, width: oementions.tableView.frame.size.width, height: 1)
  278. }
  279. }
  280. func textViewShouldUpdateTextColor() {
  281. shouldUpdateTextColor = true
  282. }
  283. }
  284. class MentionCell : UITableViewCell {
  285. var contact : Contact? {
  286. didSet {
  287. avatar.image = AvatarMaker.shared()?.avatar(for: contact!, size: 16.0, masked: true)
  288. mentionNameLabel.text = contact?.displayName
  289. mentionIdentityLabel.text = contact?.identity
  290. }
  291. }
  292. var allContact: Bool = false {
  293. didSet {
  294. avatar.image = AvatarMaker.shared()?.unknownPersonImage()
  295. mentionNameLabel.text = "@" + BundleUtil.localizedString(forKey: "all")
  296. mentionIdentityLabel.text = nil
  297. }
  298. }
  299. private let mentionNameLabel : UILabel = {
  300. let lbl = UILabel()
  301. lbl.textColor = Colors.fontNormal()
  302. lbl.font = UIFont.systemFont(ofSize: UIFont.systemFontSize)
  303. lbl.textAlignment = .left
  304. return lbl
  305. }()
  306. private let mentionIdentityLabel : UILabel = {
  307. let lbl = UILabel()
  308. lbl.textColor = Colors.fontNormal()
  309. lbl.font = UIFont.systemFont(ofSize: UIFont.systemFontSize)
  310. lbl.textAlignment = .right
  311. lbl.numberOfLines = 0
  312. lbl.lineBreakMode = .byWordWrapping
  313. return lbl
  314. }()
  315. private let avatar : UIImageView = {
  316. let imgView = UIImageView()
  317. imgView.contentMode = .scaleAspectFit
  318. imgView.clipsToBounds = true
  319. return imgView
  320. }()
  321. override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  322. super.init(style: style, reuseIdentifier: reuseIdentifier)
  323. let stretchingView = UIView()
  324. stretchingView.setContentHuggingPriority(UILayoutPriority(rawValue: 1), for: .horizontal)
  325. stretchingView.backgroundColor = .clear
  326. stretchingView.translatesAutoresizingMaskIntoConstraints = false
  327. let stackView = UIStackView(arrangedSubviews: [avatar, mentionNameLabel,stretchingView,mentionIdentityLabel])
  328. stackView.distribution = .fill
  329. stackView.axis = .horizontal
  330. stackView.spacing = 15
  331. stackView.alignment = .fill
  332. addSubview(stackView)
  333. avatar.anchor(top: nil, left: nil, bottom: nil, right: nil, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 32.0, height: 32.0, enableInsets: false)
  334. stackView.anchor(top: topAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor, paddingTop: 10, paddingLeft: 15, paddingBottom: 10, paddingRight: 15, width: 0, height: 0, enableInsets: false)
  335. }
  336. required init?(coder aDecoder: NSCoder) {
  337. fatalError("init(coder:) has not been implemented")
  338. }
  339. }
  340. extension UIView {
  341. func anchor (top: NSLayoutYAxisAnchor?, left: NSLayoutXAxisAnchor?, bottom: NSLayoutYAxisAnchor?, right: NSLayoutXAxisAnchor?, paddingTop: CGFloat, paddingLeft: CGFloat, paddingBottom: CGFloat, paddingRight: CGFloat, width: CGFloat, height: CGFloat, enableInsets: Bool) {
  342. var topInset = CGFloat(0)
  343. var bottomInset = CGFloat(0)
  344. if #available(iOS 11, *), enableInsets {
  345. let insets = self.safeAreaInsets
  346. topInset = insets.top
  347. bottomInset = insets.bottom
  348. }
  349. translatesAutoresizingMaskIntoConstraints = false
  350. if let top = top {
  351. self.topAnchor.constraint(equalTo: top, constant: paddingTop+topInset).isActive = true
  352. }
  353. if let left = left {
  354. self.leftAnchor.constraint(equalTo: left, constant: paddingLeft).isActive = true
  355. }
  356. if let right = right {
  357. rightAnchor.constraint(equalTo: right, constant: -paddingRight).isActive = true
  358. }
  359. if let bottom = bottom {
  360. bottomAnchor.constraint(equalTo: bottom, constant: -paddingBottom-bottomInset).isActive = true
  361. }
  362. if height != 0 {
  363. heightAnchor.constraint(equalToConstant: height).isActive = true
  364. }
  365. if width != 0 {
  366. widthAnchor.constraint(equalToConstant: width).isActive = true
  367. }
  368. }
  369. }