ContactDetailViewController.swift 23 KB

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