ContactDetailViewController.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import UIKit
  2. import DcCore
  3. import Intents
  4. // this is also used as ChatDetail for SingleChats
  5. class ContactDetailViewController: UITableViewController {
  6. private let viewModel: ContactDetailViewModel
  7. private lazy var headerCell: ContactDetailHeader = {
  8. let headerCell = ContactDetailHeader()
  9. headerCell.showSearchButton(show: viewModel.chatId != 0)
  10. headerCell.onAvatarTap = showContactAvatarIfNeeded
  11. headerCell.onMuteButtonTapped = toggleMuteChat
  12. headerCell.onSearchButtonTapped = showSearch
  13. headerCell.setRecentlySeen(viewModel.contact.wasSeenRecently)
  14. return headerCell
  15. }()
  16. private lazy var startChatCell: ActionCell = {
  17. let cell = ActionCell()
  18. cell.actionColor = SystemColor.blue.uiColor
  19. cell.actionTitle = String.localized("send_message")
  20. return cell
  21. }()
  22. private lazy var ephemeralMessagesCell: UITableViewCell = {
  23. let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
  24. cell.textLabel?.text = String.localized("ephemeral_messages")
  25. cell.accessoryType = .disclosureIndicator
  26. return cell
  27. }()
  28. private lazy var showEncrInfoCell: ActionCell = {
  29. let cell = ActionCell()
  30. cell.actionTitle = String.localized("encryption_info_title_desktop")
  31. cell.actionColor = SystemColor.blue.uiColor
  32. return cell
  33. }()
  34. private lazy var copyToClipboardCell: ActionCell = {
  35. let cell = ActionCell()
  36. cell.actionTitle = String.localized("menu_copy_to_clipboard")
  37. cell.actionColor = SystemColor.blue.uiColor
  38. return cell
  39. }()
  40. private lazy var blockContactCell: ActionCell = {
  41. let cell = ActionCell()
  42. cell.actionTitle = viewModel.contact.isBlocked ? String.localized("menu_unblock_contact") : String.localized("menu_block_contact")
  43. cell.actionColor = viewModel.contact.isBlocked ? SystemColor.blue.uiColor : UIColor.red
  44. return cell
  45. }()
  46. private lazy var archiveChatCell: ActionCell = {
  47. let cell = ActionCell()
  48. cell.actionTitle = viewModel.chatIsArchived ? String.localized("menu_unarchive_chat") : String.localized("menu_archive_chat")
  49. cell.actionColor = SystemColor.blue.uiColor
  50. return cell
  51. }()
  52. private lazy var deleteChatCell: ActionCell = {
  53. let cell = ActionCell()
  54. cell.actionTitle = String.localized("menu_delete_chat")
  55. cell.actionColor = UIColor.red
  56. return cell
  57. }()
  58. private lazy var galleryCell: UITableViewCell = {
  59. let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
  60. cell.textLabel?.text = String.localized("images_and_videos")
  61. cell.accessoryType = .disclosureIndicator
  62. if viewModel.chatId == 0 {
  63. cell.isUserInteractionEnabled = false
  64. cell.textLabel?.isEnabled = false
  65. }
  66. return cell
  67. }()
  68. private lazy var documentsCell: UITableViewCell = {
  69. let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
  70. cell.textLabel?.text = String.localized(viewModel.hasWebxdc ? "files_and_webxdx_apps" : "files")
  71. cell.accessoryType = .disclosureIndicator
  72. if viewModel.chatId == 0 {
  73. cell.isUserInteractionEnabled = false
  74. cell.textLabel?.isEnabled = false
  75. }
  76. return cell
  77. }()
  78. private lazy var statusCell: MultilineLabelCell = {
  79. let cell = MultilineLabelCell()
  80. cell.multilineDelegate = self
  81. return cell
  82. }()
  83. private var incomingMsgsObserver: NSObjectProtocol?
  84. private var contactChangedObserver: NSObjectProtocol?
  85. init(dcContext: DcContext, contactId: Int) {
  86. self.viewModel = ContactDetailViewModel(dcContext: dcContext, contactId: contactId)
  87. super.init(style: .grouped)
  88. }
  89. required init?(coder _: NSCoder) {
  90. fatalError("init(coder:) has not been implemented")
  91. }
  92. // MARK: - lifecycle
  93. override func viewDidLoad() {
  94. super.viewDidLoad()
  95. configureTableView()
  96. if !self.viewModel.isSavedMessages && !self.viewModel.isDeviceTalk {
  97. navigationItem.rightBarButtonItem = UIBarButtonItem(
  98. title: String.localized("global_menu_edit_desktop"),
  99. style: .plain, target: self, action: #selector(editButtonPressed))
  100. self.title = String.localized("tab_contact")
  101. } else {
  102. self.title = String.localized("profile")
  103. }
  104. }
  105. override func viewWillAppear(_ animated: Bool) {
  106. super.viewWillAppear(animated)
  107. setupObservers()
  108. updateHeader() // maybe contact name has been edited
  109. updateCellValues()
  110. tableView.reloadData()
  111. // see comment in GroupChatDetailViewController.viewWillAppear()
  112. AppDelegate.emitMsgsChangedIfShareExtensionWasUsed()
  113. }
  114. override func viewWillDisappear(_ animated: Bool) {
  115. super.viewWillDisappear(animated)
  116. removeObservers()
  117. }
  118. // MARK: - setup and configuration
  119. private func configureTableView() {
  120. tableView.register(ActionCell.self, forCellReuseIdentifier: ActionCell.reuseIdentifier)
  121. tableView.register(ContactCell.self, forCellReuseIdentifier: ContactCell.reuseIdentifier)
  122. headerCell.frame = CGRect(0, 0, tableView.frame.width, ContactCell.cellHeight)
  123. tableView.tableHeaderView = headerCell
  124. tableView.sectionHeaderHeight = UITableView.automaticDimension
  125. tableView.rowHeight = UITableView.automaticDimension
  126. }
  127. // MARK: - UITableViewDatasource, UITableViewDelegate
  128. override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  129. if previousTraitCollection?.preferredContentSizeCategory !=
  130. traitCollection.preferredContentSizeCategory {
  131. headerCell.frame = CGRect(0, 0, tableView.frame.width, ContactCell.cellHeight)
  132. }
  133. }
  134. override func numberOfSections(in tableView: UITableView) -> Int {
  135. return viewModel.numberOfSections
  136. }
  137. override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  138. return viewModel.numberOfRowsInSection(section)
  139. }
  140. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  141. let row = indexPath.row
  142. let cellType = viewModel.typeFor(section: indexPath.section)
  143. switch cellType {
  144. case .chatOptions:
  145. switch viewModel.chatOptionFor(row: row) {
  146. case .documents:
  147. return documentsCell
  148. case .gallery:
  149. return galleryCell
  150. case .ephemeralMessages:
  151. return ephemeralMessagesCell
  152. case .startChat:
  153. return startChatCell
  154. }
  155. case .statusArea:
  156. return statusCell
  157. case .chatActions:
  158. switch viewModel.chatActionFor(row: row) {
  159. case .archiveChat:
  160. return archiveChatCell
  161. case .showEncrInfo:
  162. return showEncrInfoCell
  163. case .copyToClipboard:
  164. return copyToClipboardCell
  165. case .blockContact:
  166. return blockContactCell
  167. case .deleteChat:
  168. return deleteChatCell
  169. }
  170. case .sharedChats:
  171. if let cell = tableView.dequeueReusableCell(withIdentifier: ContactCell.reuseIdentifier, for: indexPath) as? ContactCell {
  172. viewModel.update(sharedChatCell: cell, row: row)
  173. cell.backgroundColor = DcColors.profileCellBackgroundColor
  174. return cell
  175. }
  176. }
  177. return UITableViewCell() // should never get here
  178. }
  179. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  180. let type = viewModel.typeFor(section: indexPath.section)
  181. switch type {
  182. case .chatOptions:
  183. handleChatOption(indexPath: indexPath)
  184. case .statusArea:
  185. break
  186. case .chatActions:
  187. handleChatAction(indexPath: indexPath)
  188. case .sharedChats:
  189. let chatId = viewModel.getSharedChatIdAt(indexPath: indexPath)
  190. showChat(chatId: chatId)
  191. }
  192. }
  193. override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
  194. let type = viewModel.typeFor(section: indexPath.section)
  195. switch type {
  196. case .sharedChats:
  197. return ContactCell.cellHeight
  198. default:
  199. return UITableView.automaticDimension
  200. }
  201. }
  202. override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  203. return viewModel.titleFor(section: section)
  204. }
  205. override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
  206. return viewModel.footerFor(section: section)
  207. }
  208. // MARK: - observers
  209. private func setupObservers() {
  210. let nc = NotificationCenter.default
  211. contactChangedObserver = nc.addObserver(
  212. forName: dcNotificationContactChanged,
  213. object: nil,
  214. queue: OperationQueue.main) { [weak self] notification in
  215. guard let self = self else { return }
  216. if let ui = notification.userInfo,
  217. self.viewModel.contactId == ui["contact_id"] as? Int {
  218. self.updateHeader()
  219. }
  220. }
  221. incomingMsgsObserver = nc.addObserver(
  222. forName: dcNotificationIncoming,
  223. object: nil,
  224. queue: OperationQueue.main) { [weak self] notification in
  225. guard let self = self else { return }
  226. if let ui = notification.userInfo,
  227. let chatId = ui["chat_id"] as? Int {
  228. if self.viewModel.getSharedChatIds().contains(chatId) {
  229. self.viewModel.updateSharedChats()
  230. if self.viewModel.chatId == chatId {
  231. self.updateCellValues()
  232. }
  233. self.tableView.reloadData()
  234. }
  235. }
  236. }
  237. }
  238. private func removeObservers() {
  239. let nc = NotificationCenter.default
  240. if let contactChangedObserver = self.contactChangedObserver {
  241. nc.removeObserver(contactChangedObserver)
  242. }
  243. if let incomingMsgsObserver = self.incomingMsgsObserver {
  244. nc.removeObserver(incomingMsgsObserver)
  245. }
  246. }
  247. // MARK: - updates
  248. private func updateHeader() {
  249. if viewModel.isSavedMessages {
  250. let chat = viewModel.context.getChat(chatId: viewModel.chatId)
  251. headerCell.updateDetails(title: chat.name, subtitle: String.localized("chat_self_talk_subtitle"))
  252. if let img = chat.profileImage {
  253. headerCell.setImage(img)
  254. } else {
  255. headerCell.setBackupImage(name: chat.name, color: chat.color)
  256. }
  257. headerCell.setVerified(isVerified: false)
  258. headerCell.showMuteButton(show: false)
  259. } else {
  260. headerCell.updateDetails(title: viewModel.contact.displayName,
  261. subtitle: viewModel.isDeviceTalk ? String.localized("device_talk_subtitle") : viewModel.contact.email)
  262. if let img = viewModel.contact.profileImage {
  263. headerCell.setImage(img)
  264. } else {
  265. headerCell.setBackupImage(name: viewModel.contact.displayName, color: viewModel.contact.color)
  266. }
  267. headerCell.setVerified(isVerified: viewModel.contact.isVerified)
  268. headerCell.setMuted(isMuted: viewModel.chatIsMuted)
  269. headerCell.showMuteButton(show: true)
  270. }
  271. headerCell.showSearchButton(show: viewModel.chatCanSend)
  272. }
  273. private func updateCellValues() {
  274. ephemeralMessagesCell.detailTextLabel?.text = String.localized(viewModel.chatIsEphemeral ? "on" : "off")
  275. galleryCell.detailTextLabel?.text = String.numberOrNone(viewModel.galleryItemMessageIds.count)
  276. documentsCell.detailTextLabel?.text = String.numberOrNone(viewModel.documentItemMessageIds.count)
  277. statusCell.setText(text: viewModel.contact.status)
  278. }
  279. // MARK: - actions
  280. private func handleChatAction(indexPath: IndexPath) {
  281. let action = viewModel.chatActionFor(row: indexPath.row)
  282. switch action {
  283. case .archiveChat:
  284. tableView.deselectRow(at: indexPath, animated: true) // animated as no other elements pop up
  285. toggleArchiveChat()
  286. case .showEncrInfo:
  287. tableView.deselectRow(at: indexPath, animated: false)
  288. showEncrInfoAlert()
  289. case .copyToClipboard:
  290. tableView.deselectRow(at: indexPath, animated: true)
  291. UIPasteboard.general.string = viewModel.contact.email
  292. case .blockContact:
  293. tableView.deselectRow(at: indexPath, animated: false)
  294. toggleBlockContact()
  295. case .deleteChat:
  296. tableView.deselectRow(at: indexPath, animated: false)
  297. showDeleteChatConfirmationAlert()
  298. }
  299. }
  300. private func handleChatOption(indexPath: IndexPath) {
  301. let action = viewModel.chatOptionFor(row: indexPath.row)
  302. switch action {
  303. case .documents:
  304. showDocuments()
  305. case .gallery:
  306. showGallery()
  307. case .ephemeralMessages:
  308. showEphemeralMessagesController()
  309. case .startChat:
  310. tableView.deselectRow(at: indexPath, animated: false)
  311. let contactId = viewModel.contactId
  312. chatWith(contactId: contactId)
  313. }
  314. }
  315. private func toggleArchiveChat() {
  316. let archived = viewModel.toggleArchiveChat()
  317. if archived {
  318. self.navigationController?.popToRootViewController(animated: false)
  319. } else {
  320. archiveChatCell.actionTitle = String.localized("menu_archive_chat")
  321. }
  322. }
  323. private func toggleMuteChat() {
  324. if viewModel.chatIsMuted {
  325. self.viewModel.context.setChatMuteDuration(chatId: self.viewModel.chatId, duration: 0)
  326. headerCell.setMuted(isMuted: viewModel.chatIsMuted)
  327. self.navigationController?.popViewController(animated: true)
  328. } else {
  329. showMuteAlert()
  330. }
  331. }
  332. private func updateBlockContactCell() {
  333. blockContactCell.actionTitle = viewModel.contact.isBlocked ? String.localized("menu_unblock_contact") : String.localized("menu_block_contact")
  334. blockContactCell.actionColor = viewModel.contact.isBlocked ? SystemColor.blue.uiColor : UIColor.red
  335. }
  336. @objc private func editButtonPressed() {
  337. showEditContact(contactId: viewModel.contactId)
  338. }
  339. // MARK: alerts
  340. private func showDeleteChatConfirmationAlert() {
  341. let alert = UIAlertController(
  342. title: nil,
  343. message: String.localizedStringWithFormat(String.localized("ask_delete_named_chat"), viewModel.context.getChat(chatId: viewModel.chatId).name),
  344. preferredStyle: .safeActionSheet
  345. )
  346. alert.addAction(UIAlertAction(title: String.localized("menu_delete_chat"), style: .destructive, handler: { _ in
  347. self.deleteChat()
  348. }))
  349. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  350. self.present(alert, animated: true, completion: nil)
  351. }
  352. private func showEncrInfoAlert() {
  353. let alert = UIAlertController(
  354. title: nil,
  355. message: self.viewModel.context.getContactEncrInfo(contactId: self.viewModel.contactId),
  356. preferredStyle: .alert
  357. )
  358. alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
  359. self.present(alert, animated: true, completion: nil)
  360. }
  361. private func showEphemeralMessagesController() {
  362. let ephemeralMessagesController = EphemeralMessagesViewController(dcContext: viewModel.context, chatId: viewModel.chatId)
  363. navigationController?.pushViewController(ephemeralMessagesController, animated: true)
  364. }
  365. private func showMuteAlert() {
  366. let alert = UIAlertController(title: String.localized("mute"), message: nil, preferredStyle: .safeActionSheet)
  367. let forever = -1
  368. addDurationSelectionAction(to: alert, key: "mute_for_one_hour", duration: Time.oneHour)
  369. addDurationSelectionAction(to: alert, key: "mute_for_two_hours", duration: Time.twoHours)
  370. addDurationSelectionAction(to: alert, key: "mute_for_one_day", duration: Time.oneDay)
  371. addDurationSelectionAction(to: alert, key: "mute_for_seven_days", duration: Time.oneWeek)
  372. addDurationSelectionAction(to: alert, key: "mute_forever", duration: forever)
  373. let cancelAction = UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil)
  374. alert.addAction(cancelAction)
  375. present(alert, animated: true, completion: nil)
  376. }
  377. private func addDurationSelectionAction(to alert: UIAlertController, key: String, duration: Int) {
  378. let action = UIAlertAction(title: String.localized(key), style: .default, handler: { _ in
  379. self.viewModel.context.setChatMuteDuration(chatId: self.viewModel.chatId, duration: duration)
  380. self.headerCell.setMuted(isMuted: self.viewModel.chatIsMuted)
  381. self.navigationController?.popViewController(animated: true)
  382. })
  383. alert.addAction(action)
  384. }
  385. private func toggleBlockContact() {
  386. if viewModel.contact.isBlocked {
  387. let alert = UIAlertController(title: String.localized("ask_unblock_contact"), message: nil, preferredStyle: .safeActionSheet)
  388. alert.addAction(UIAlertAction(title: String.localized("menu_unblock_contact"), style: .default, handler: { _ in
  389. self.viewModel.unblockContact()
  390. self.updateBlockContactCell()
  391. }))
  392. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  393. present(alert, animated: true, completion: nil)
  394. } else {
  395. let alert = UIAlertController(title: String.localized("ask_block_contact"), message: nil, preferredStyle: .safeActionSheet)
  396. alert.addAction(UIAlertAction(title: String.localized("menu_block_contact"), style: .destructive, handler: { _ in
  397. self.viewModel.blockContact()
  398. self.updateBlockContactCell()
  399. }))
  400. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  401. present(alert, animated: true, completion: nil)
  402. }
  403. }
  404. private func chatWith(contactId: Int) {
  405. let chatId = self.viewModel.context.createChatByContactId(contactId: contactId)
  406. self.showChat(chatId: chatId)
  407. }
  408. // MARK: - coordinator
  409. private func showChat(chatId: Int) {
  410. if let chatlistViewController = navigationController?.viewControllers[0] {
  411. let chatViewController = ChatViewController(dcContext: viewModel.context, chatId: chatId)
  412. navigationController?.setViewControllers([chatlistViewController, chatViewController], animated: true)
  413. }
  414. }
  415. private func showEditContact(contactId: Int) {
  416. let editContactController = EditContactController(dcContext: viewModel.context, contactIdForUpdate: contactId)
  417. navigationController?.pushViewController(editContactController, animated: true)
  418. }
  419. private func showDocuments() {
  420. let messageIds: [Int] = viewModel.documentItemMessageIds.reversed()
  421. let fileGalleryController = FilesViewController(context: viewModel.context, chatId: viewModel.chatId, fileMessageIds: messageIds, hasWebxdc: viewModel.hasWebxdc)
  422. navigationController?.pushViewController(fileGalleryController, animated: true)
  423. }
  424. private func showGallery() {
  425. let messageIds: [Int] = viewModel.galleryItemMessageIds.reversed()
  426. let galleryController = GalleryViewController(context: viewModel.context, chatId: viewModel.chatId, mediaMessageIds: messageIds)
  427. navigationController?.pushViewController(galleryController, animated: true)
  428. }
  429. private func showSearch() {
  430. if let chatViewController = navigationController?.viewControllers.last(where: {
  431. $0 is ChatViewController
  432. }) as? ChatViewController {
  433. chatViewController.activateSearchOnAppear()
  434. navigationController?.popViewController(animated: true)
  435. }
  436. }
  437. private func showContactAvatarIfNeeded() {
  438. if viewModel.isSavedMessages {
  439. let chat = viewModel.context.getChat(chatId: viewModel.chatId)
  440. if let url = chat.profileImageURL {
  441. let previewController = PreviewController(dcContext: viewModel.context, type: .single(url))
  442. previewController.customTitle = chat.name
  443. navigationController?.pushViewController(previewController, animated: true)
  444. }
  445. } else if let url = viewModel.contact.profileImageURL {
  446. let previewController = PreviewController(dcContext: viewModel.context, type: .single(url))
  447. previewController.customTitle = viewModel.contact.displayName
  448. navigationController?.pushViewController(previewController, animated: true)
  449. }
  450. }
  451. private func deleteChat() {
  452. if viewModel.chatId == 0 {
  453. return
  454. }
  455. viewModel.context.deleteChat(chatId: viewModel.chatId)
  456. NotificationManager.removeNotificationsForChat(dcContext: viewModel.context, chatId: viewModel.chatId)
  457. INInteraction.delete(with: ["\(viewModel.context.id).\(viewModel.chatId)"])
  458. navigationController?.popViewControllers(viewsToPop: 2, animated: true)
  459. }
  460. }
  461. extension ContactDetailViewController: MultilineLabelCellDelegate {
  462. func phoneNumberTapped(number: String) {
  463. let sanitizedNumber = number.filter("0123456789".contains)
  464. if let phoneURL = URL(string: "tel://\(sanitizedNumber)") {
  465. UIApplication.shared.open(phoneURL, options: [:], completionHandler: nil)
  466. }
  467. }
  468. func urlTapped(url: URL) {
  469. if Utils.isEmail(url: url) {
  470. let email = Utils.getEmailFrom(url)
  471. let contactId = viewModel.context.createContact(name: "", email: email)
  472. let alert = UIAlertController(title: String.localizedStringWithFormat(String.localized("ask_start_chat_with"), email),
  473. message: nil, preferredStyle: .safeActionSheet)
  474. alert.addAction(UIAlertAction(title: String.localized("start_chat"), style: .default, handler: { [weak self] _ in
  475. guard let self = self else { return }
  476. let chatId = self.viewModel.context.createChatByContactId(contactId: contactId)
  477. if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
  478. appDelegate.appCoordinator.showChat(chatId: chatId, clearViewControllerStack: true)
  479. }
  480. }))
  481. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  482. present(alert, animated: true, completion: nil)
  483. } else {
  484. UIApplication.shared.open(url)
  485. }
  486. }
  487. }