InputbarViewController.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. //
  2. // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. import Foundation
  6. import NextcloudKit
  7. import PhotosUI
  8. import UIKit
  9. @objcMembers public class InputbarViewController: SLKTextViewController, NCChatTitleViewDelegate {
  10. // MARK: - Public var
  11. public var room: NCRoom
  12. // MARK: - Internal var
  13. internal var titleView: NCChatTitleView?
  14. internal var autocompletionUsers: [MentionSuggestion] = []
  15. internal var mentionsDict: [String: NCMessageParameter] = [:]
  16. internal var contentView: UIView?
  17. public init?(for room: NCRoom, tableViewStyle style: UITableView.Style) {
  18. self.room = room
  19. super.init(tableViewStyle: style)
  20. self.commonInit()
  21. }
  22. public init?(for room: NCRoom, withView view: UIView) {
  23. self.room = room
  24. self.contentView = view
  25. super.init(tableViewStyle: .plain)
  26. self.commonInit()
  27. view.translatesAutoresizingMaskIntoConstraints = false
  28. self.view.addSubview(view)
  29. NSLayoutConstraint.activate([
  30. view.leftAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leftAnchor),
  31. view.rightAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.rightAnchor),
  32. view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
  33. view.bottomAnchor.constraint(equalTo: self.textInputbar.topAnchor)
  34. ])
  35. // Make sure our contentView does not hide the inputBar and the autocompletionView
  36. self.view.bringSubviewToFront(self.textInputbar)
  37. self.view.bringSubviewToFront(self.autoCompletionView)
  38. }
  39. private func commonInit() {
  40. self.registerClass(forTextView: NCMessageTextView.self)
  41. self.registerClass(forReplyView: ReplyMessageView.self)
  42. self.registerClass(forTypingIndicatorView: TypingIndicatorView.self)
  43. }
  44. required init?(coder decoder: NSCoder) {
  45. fatalError("init(coder:) has not been implemented")
  46. }
  47. deinit {
  48. print("Dealloc InputbarViewController")
  49. }
  50. // MARK: - View lifecycle
  51. public override func viewDidLoad() {
  52. super.viewDidLoad()
  53. self.setTitleView()
  54. self.bounces = false
  55. self.shakeToClearEnabled = false
  56. self.textInputbar.autoHideRightButton = false
  57. self.textInputbar.counterStyle = .limitExceeded
  58. self.textInputbar.counterPosition = .top
  59. self.textInputbar.textView.isDynamicTypeEnabled = false
  60. self.textInputbar.textView.font = .preferredFont(forTextStyle: .body)
  61. let talkCapabilities = NCDatabaseManager.sharedInstance().roomTalkCapabilities(for: room)
  62. if let talkCapabilities, talkCapabilities.chatMaxLength > 0 {
  63. self.textInputbar.maxCharCount = UInt(talkCapabilities.chatMaxLength)
  64. } else {
  65. self.textInputbar.maxCharCount = 1000
  66. self.textInputbar.counterStyle = .countdownReversed
  67. }
  68. self.textInputbar.isTranslucent = false
  69. self.textInputbar.semanticContentAttribute = .forceLeftToRight
  70. self.textInputbar.contentInset = .init(top: 8, left: 4, bottom: 8, right: 4)
  71. self.textView.textContainerInset = .init(top: 8, left: 8, bottom: 8, right: 8)
  72. self.textView.layoutSubviews()
  73. self.textView.layer.cornerRadius = self.textView.frame.size.height / 2
  74. // Need a compile-time check here for old xcode version on CI
  75. #if swift(>=5.9)
  76. if #available(iOS 17.0, *), NCUtils.isiOSAppOnMac() {
  77. self.textView.inlinePredictionType = .no
  78. }
  79. #endif
  80. self.textInputbar.editorTitle.textColor = .darkGray
  81. self.textInputbar.editorLeftButton.tintColor = .systemBlue
  82. self.textInputbar.editorRightButton.tintColor = .systemBlue
  83. self.textInputbar.editorLeftButton.setImage(.init(systemName: "xmark"), for: .normal)
  84. self.textInputbar.editorRightButton.setImage(.init(systemName: "checkmark"), for: .normal)
  85. self.textInputbar.editorLeftButton.setTitle("", for: .normal)
  86. self.textInputbar.editorRightButton.setTitle("", for: .normal)
  87. self.navigationController?.navigationBar.tintColor = NCAppBranding.themeTextColor()
  88. self.navigationController?.navigationBar.barTintColor = NCAppBranding.themeColor()
  89. self.navigationController?.navigationBar.isTranslucent = false
  90. self.tabBarController?.tabBar.tintColor = NCAppBranding.themeColor()
  91. let themeColor: UIColor = NCAppBranding.themeColor()
  92. let themeTextColor: UIColor = NCAppBranding.themeTextColor()
  93. let appearance = UINavigationBarAppearance()
  94. appearance.configureWithOpaqueBackground()
  95. appearance.titleTextAttributes = [.foregroundColor: themeTextColor]
  96. appearance.backgroundColor = themeColor
  97. self.navigationItem.standardAppearance = appearance
  98. self.navigationItem.compactAppearance = appearance
  99. self.navigationItem.scrollEdgeAppearance = appearance
  100. self.view.backgroundColor = .systemBackground
  101. self.textInputbar.backgroundColor = .systemBackground
  102. self.textInputbar.editorTitle.textColor = .label
  103. self.textView.layer.borderWidth = 1.0
  104. self.textView.layer.borderColor = UIColor.systemGray4.cgColor
  105. // Hide default top border of UIToolbar
  106. self.textInputbar.setShadowImage(UIImage(), forToolbarPosition: .any)
  107. self.textView.delegate = self
  108. self.autoCompletionView.register(AutoCompletionTableViewCell.self, forCellReuseIdentifier: AutoCompletionCellIdentifier)
  109. self.registerPrefixes(forAutoCompletion: ["@"])
  110. self.autoCompletionView.backgroundColor = .secondarySystemBackground
  111. self.autoCompletionView.sectionHeaderTopPadding = 0
  112. // Align separators to ChatMessageTableViewCell's title label
  113. self.autoCompletionView.separatorInset = .init(top: 0, left: 50, bottom: 0, right: 0)
  114. // We can't use UIColor with systemBlueColor directly, because it will switch to indigo. So make sure we actually get a blue tint here
  115. self.textView.tintColor = UIColor(cgColor: UIColor.systemBlue.cgColor)
  116. self.restorePendingMessage()
  117. self.rightButton.setTitle("", for: .normal)
  118. self.rightButton.setImage(UIImage(systemName: "paperplane"), for: .normal)
  119. self.rightButton.accessibilityLabel = NSLocalizedString("Send message", comment: "")
  120. self.rightButton.accessibilityHint = NSLocalizedString("Double tap to send message", comment: "")
  121. }
  122. public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  123. if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
  124. // We use a CGColor so we loose the automatic color changing of dynamic colors -> update manually
  125. self.textView.layer.borderColor = UIColor.systemGray4.cgColor
  126. self.textView.tintColor = UIColor(cgColor: UIColor.systemBlue.cgColor)
  127. }
  128. }
  129. public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  130. super.viewWillTransition(to: size, with: coordinator)
  131. coordinator.animate(alongsideTransition: nil) { _ in
  132. self.setTitleView()
  133. }
  134. }
  135. // MARK: - Configuration
  136. func setTitleView() {
  137. let titleView = NCChatTitleView()
  138. // Int.max is problematic when running on MacOS, so we use Int32.max here
  139. titleView.frame = .init(x: 0, y: 0, width: Int(Int32.max), height: 30)
  140. titleView.delegate = self
  141. titleView.titleTextView.accessibilityHint = NSLocalizedString("Double tap to go to conversation information", comment: "")
  142. if self.navigationController?.traitCollection.verticalSizeClass == .compact {
  143. titleView.showSubtitle = false
  144. }
  145. titleView.update(for: self.room)
  146. self.titleView = titleView
  147. self.navigationItem.titleView = titleView
  148. }
  149. // MARK: - Autocompletion
  150. public override func didChangeAutoCompletionPrefix(_ prefix: String, andWord word: String) {
  151. if prefix == "@" {
  152. self.showSuggestions(for: word)
  153. }
  154. }
  155. public override func heightForAutoCompletionView() -> CGFloat {
  156. return kAutoCompletionCellHeight * CGFloat(self.autocompletionUsers.count) + (self.autoCompletionView.tableHeaderView?.frame.height ?? 0)
  157. }
  158. func showSuggestions(for string: String) {
  159. self.autocompletionUsers = []
  160. let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
  161. NCAPIController.sharedInstance().getMentionSuggestions(for: activeAccount.accountId, in: self.room.token, with: string) { mentions in
  162. guard let mentions else { return }
  163. self.autocompletionUsers = mentions
  164. let showAutocomplete = !self.autocompletionUsers.isEmpty
  165. // Check if "@" is still there
  166. self.textView.look(forPrefixes: self.registeredPrefixes) { prefix, word, _ in
  167. if prefix?.count ?? 0 > 0 && word?.count ?? 0 > 0 {
  168. self.showAutoCompletionView(showAutocomplete)
  169. } else {
  170. self.cancelAutoCompletion()
  171. }
  172. }
  173. }
  174. }
  175. internal func replaceMentionsDisplayNamesWithMentionsKeysInMessage(message: String, parameters: String) -> String {
  176. var resultMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
  177. guard let messageParametersDict = NCMessageParameter.messageParametersDict(fromJSONString: parameters) else { return resultMessage }
  178. for (parameterKey, parameter) in messageParametersDict {
  179. let parameterKeyString = "{\(parameterKey)}"
  180. resultMessage = resultMessage.replacingOccurrences(of: parameter.mentionDisplayName, with: parameterKeyString)
  181. }
  182. return resultMessage
  183. }
  184. // MARK: - UITableViewDataSource methods
  185. public override func numberOfSections(in tableView: UITableView) -> Int {
  186. if tableView == self.autoCompletionView {
  187. return 1
  188. }
  189. return 0
  190. }
  191. public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  192. if tableView == self.autoCompletionView {
  193. return self.autocompletionUsers.count
  194. }
  195. return 0
  196. }
  197. public override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  198. return nil
  199. }
  200. public override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
  201. return 0
  202. }
  203. public override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
  204. return nil
  205. }
  206. public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  207. guard tableView == self.autoCompletionView,
  208. indexPath.row < self.autocompletionUsers.count,
  209. let cell = self.autoCompletionView.dequeueReusableCell(withIdentifier: AutoCompletionCellIdentifier) as? AutoCompletionTableViewCell
  210. else {
  211. return AutoCompletionTableViewCell(style: .default, reuseIdentifier: AutoCompletionCellIdentifier)
  212. }
  213. let suggestion = self.autocompletionUsers[indexPath.row]
  214. if let details = suggestion.details {
  215. cell.titleLabel.numberOfLines = 2
  216. let attributedLabel = (suggestion.label + "\n").withFont(.preferredFont(forTextStyle: .body))
  217. let attributedDetails = details.withFont(.preferredFont(forTextStyle: .callout)).withTextColor(.secondaryLabel)
  218. attributedLabel.append(attributedDetails)
  219. cell.titleLabel.attributedText = attributedLabel
  220. } else {
  221. cell.titleLabel.numberOfLines = 1
  222. cell.titleLabel.text = suggestion.label
  223. }
  224. if let suggestionUserStatus = suggestion.userStatus {
  225. cell.setUserStatus(suggestionUserStatus)
  226. }
  227. if suggestion.id == "all" {
  228. cell.avatarButton.setAvatar(for: self.room)
  229. } else {
  230. cell.avatarButton.setActorAvatar(forId: suggestion.id, withType: suggestion.source, withDisplayName: suggestion.label, withRoomToken: self.room.token)
  231. }
  232. cell.accessibilityIdentifier = AutoCompletionCellIdentifier
  233. return cell
  234. }
  235. public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  236. guard tableView == self.autoCompletionView,
  237. indexPath.row < self.autocompletionUsers.count
  238. else { return }
  239. let suggestion = self.autocompletionUsers[indexPath.row]
  240. let mentionKey = "mention-\(self.mentionsDict.count)"
  241. self.mentionsDict[mentionKey] = suggestion.asMessageParameter()
  242. let mentionWithWhitespace = suggestion.label + " "
  243. self.acceptAutoCompletion(with: mentionWithWhitespace, keepPrefix: true)
  244. }
  245. public override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
  246. return kAutoCompletionCellHeight
  247. }
  248. // MARK: - TextView functiosn
  249. public func setChatMessage(_ chatMessage: String) {
  250. DispatchQueue.main.async {
  251. self.textView.text = chatMessage
  252. }
  253. }
  254. public func restorePendingMessage() {
  255. if let pendingMessage = self.room.pendingMessage {
  256. self.setChatMessage(pendingMessage)
  257. }
  258. }
  259. public override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
  260. if text.isEmpty, let selectedRange = textView.selectedTextRange, let text = textView.text {
  261. let cursorOffset = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
  262. let substring = (text as NSString).substring(to: cursorOffset)
  263. if var lastPossibleMention = substring.components(separatedBy: "@").last {
  264. lastPossibleMention.insert("@", at: lastPossibleMention.startIndex)
  265. for (mentionKey, mentionParameter) in self.mentionsDict {
  266. if lastPossibleMention != mentionParameter.mentionDisplayName {
  267. continue
  268. }
  269. // Delete mention
  270. let range = NSRange(location: cursorOffset - lastPossibleMention.utf16.count, length: lastPossibleMention.utf16.count)
  271. textView.text = (text as NSString).replacingCharacters(in: range, with: "")
  272. // Only delete it from mentionsDict if there are no more mentions for that user/room
  273. // User could have manually added the mention without selecting it from autocompletion
  274. // so no mention was added to the mentionsDict
  275. if (textView.text as NSString).range(of: lastPossibleMention).location != NSNotFound {
  276. self.mentionsDict.removeValue(forKey: mentionKey)
  277. }
  278. return true
  279. }
  280. }
  281. }
  282. return super.textView(textView, shouldChangeTextIn: range, replacementText: text)
  283. }
  284. // MARK: - TitleView delegate
  285. public func chatTitleViewTapped(_ titleView: NCChatTitleView!) {
  286. // Doing nothing here -> override in subclass
  287. }
  288. }