ChatListController.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  1. import UIKit
  2. import DcCore
  3. class ChatListController: UITableViewController, AccountSwitcherHandler {
  4. var viewModel: ChatListViewModel?
  5. let dcContext: DcContext
  6. internal let dcAccounts: DcAccounts
  7. var isArchive: Bool
  8. private let chatCellReuseIdentifier = "chat_cell"
  9. private let deadDropCellReuseIdentifier = "deaddrop_cell"
  10. private let contactCellReuseIdentifier = "contact_cell"
  11. private var msgChangedObserver: NSObjectProtocol?
  12. private var msgsNoticedObserver: NSObjectProtocol?
  13. private var incomingMsgObserver: NSObjectProtocol?
  14. private var incomingMsgAnyAccountObserver: NSObjectProtocol?
  15. private var chatModifiedObserver: NSObjectProtocol?
  16. private var contactsChangedObserver: NSObjectProtocol?
  17. private var connectivityChangedObserver: NSObjectProtocol?
  18. private var msgChangedSearchResultObserver: NSObjectProtocol?
  19. private weak var timer: Timer?
  20. private lazy var titleView: UILabel = {
  21. let view = UILabel()
  22. let navTapGesture = UITapGestureRecognizer(target: self, action: #selector(onNavigationTitleTapped))
  23. view.addGestureRecognizer(navTapGesture)
  24. view.isUserInteractionEnabled = true
  25. view.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
  26. view.accessibilityTraits = .header
  27. return view
  28. }()
  29. private lazy var searchController: UISearchController = {
  30. let searchController = UISearchController(searchResultsController: nil)
  31. searchController.obscuresBackgroundDuringPresentation = false
  32. searchController.searchBar.placeholder = String.localized("search")
  33. return searchController
  34. }()
  35. private lazy var archiveCell: ActionCell = {
  36. let actionCell = ActionCell()
  37. return actionCell
  38. }()
  39. private lazy var newButton: UIBarButtonItem = {
  40. let button = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.compose, target: self, action: #selector(didPressNewChat))
  41. button.tintColor = DcColors.primary
  42. return button
  43. }()
  44. private lazy var cancelButton: UIBarButtonItem = {
  45. let button = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
  46. return button
  47. }()
  48. private lazy var emptyStateLabel: EmptyStateLabel = {
  49. let label = EmptyStateLabel()
  50. label.isHidden = true
  51. return label
  52. }()
  53. private lazy var editingBar: ChatListEditingBar = {
  54. let editingBar = ChatListEditingBar()
  55. editingBar.translatesAutoresizingMaskIntoConstraints = false
  56. editingBar.delegate = self
  57. editingBar.showArchive = !isArchive
  58. return editingBar
  59. }()
  60. private lazy var accountButtonAvatar: InitialsBadge = {
  61. let badge = InitialsBadge(size: 37, accessibilityLabel: String.localized("switch_account"))
  62. badge.setLabelFont(UIFont.systemFont(ofSize: 14))
  63. badge.accessibilityTraits = .button
  64. let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(accountButtonTapped))
  65. badge.addGestureRecognizer(tapGestureRecognizer)
  66. return badge
  67. }()
  68. private lazy var accountButton: UIBarButtonItem = {
  69. return UIBarButtonItem(customView: accountButtonAvatar)
  70. }()
  71. private var editingConstraints: NSLayoutConstraintSet?
  72. init(dcContext: DcContext, dcAccounts: DcAccounts, isArchive: Bool) {
  73. self.dcContext = dcContext
  74. self.dcAccounts = dcAccounts
  75. self.isArchive = isArchive
  76. super.init(style: .grouped)
  77. DispatchQueue.global(qos: .userInteractive).async { [weak self] in
  78. guard let self = self else { return }
  79. self.viewModel = ChatListViewModel(dcContext: self.dcContext, isArchive: isArchive)
  80. self.viewModel?.onChatListUpdate = self.handleChatListUpdate
  81. DispatchQueue.main.async { [weak self] in
  82. guard let self = self else { return }
  83. if !isArchive {
  84. self.navigationItem.searchController = self.searchController
  85. self.searchController.searchResultsUpdater = self.viewModel
  86. self.searchController.searchBar.delegate = self
  87. }
  88. self.handleChatListUpdate()
  89. }
  90. }
  91. }
  92. required init?(coder _: NSCoder) {
  93. fatalError("init(coder:) has not been implemented")
  94. }
  95. // MARK: - lifecycle
  96. override func viewDidLoad() {
  97. super.viewDidLoad()
  98. configureTableView()
  99. setupSubviews()
  100. // update messages - for new messages, do not reuse or modify strings but create new ones.
  101. // it is not needed to keep all past update messages, however, when deleted, also the strings should be deleted.
  102. let msg = dcContext.newMessage(viewType: DC_MSG_TEXT)
  103. msg.text = "Some 1.34 Highlights:\n\n"
  104. + "🤗 Friendlier contact lists: Ordered by last seen and contacts seen within 10 minutes are marked by a dot 🟢\n\n"
  105. + "🔘 New account selector atop of the chat list\n\n"
  106. + "☝️ Drag'n'Drop: Eg. long tap an image in the system's gallery and navigate to the desired chat using a ✌️ second finger"
  107. dcContext.addDeviceMessage(label: "update_1_34d_ios", msg: msg)
  108. }
  109. override func willMove(toParent parent: UIViewController?) {
  110. super.willMove(toParent: parent)
  111. if parent == nil {
  112. // logger.debug("chat observer: remove")
  113. removeObservers()
  114. } else {
  115. // logger.debug("chat observer: setup")
  116. addObservers()
  117. }
  118. }
  119. override func viewWillAppear(_ animated: Bool) {
  120. super.viewWillAppear(animated)
  121. // create view
  122. navigationItem.titleView = titleView
  123. updateTitle()
  124. if RelayHelper.shared.isForwarding() {
  125. quitSearch(animated: false)
  126. tableView.scrollToTop()
  127. }
  128. }
  129. override func viewDidAppear(_ animated: Bool) {
  130. super.viewDidAppear(animated)
  131. startTimer()
  132. }
  133. override func viewDidDisappear(_ animated: Bool) {
  134. super.viewDidDisappear(animated)
  135. stopTimer()
  136. }
  137. // MARK: - setup
  138. private func addObservers() {
  139. let nc = NotificationCenter.default
  140. connectivityChangedObserver = nc.addObserver(forName: dcNotificationConnectivityChanged,
  141. object: nil,
  142. queue: nil) { [weak self] _ in
  143. self?.updateTitle()
  144. }
  145. msgChangedSearchResultObserver = nc.addObserver(
  146. forName: dcNotificationChanged,
  147. object: nil,
  148. queue: nil) { [weak self] _ in
  149. guard let self = self else { return }
  150. if let appDelegate = UIApplication.shared.delegate as? AppDelegate,
  151. let viewModel = self.viewModel,
  152. viewModel.searchActive,
  153. appDelegate.appIsInForeground() {
  154. viewModel.updateSearchResults(for: self.searchController)
  155. }
  156. }
  157. msgChangedObserver = nc.addObserver(
  158. forName: dcNotificationChanged,
  159. object: nil,
  160. queue: nil) { [weak self] _ in
  161. self?.refreshInBg()
  162. }
  163. msgsNoticedObserver = nc.addObserver(
  164. forName: dcMsgsNoticed,
  165. object: nil,
  166. queue: nil) { [weak self] _ in
  167. self?.refreshInBg()
  168. }
  169. incomingMsgObserver = nc.addObserver(
  170. forName: dcNotificationIncoming,
  171. object: nil,
  172. queue: nil) { [weak self] _ in
  173. self?.refreshInBg()
  174. }
  175. incomingMsgAnyAccountObserver = nc.addObserver(
  176. forName: dcNotificationIncomingAnyAccount,
  177. object: nil,
  178. queue: nil) { [weak self] _ in
  179. self?.updateAccountButton()
  180. }
  181. chatModifiedObserver = nc.addObserver(
  182. forName: dcNotificationChatModified,
  183. object: nil,
  184. queue: nil) { [weak self] _ in
  185. self?.refreshInBg()
  186. }
  187. contactsChangedObserver = nc.addObserver(
  188. forName: dcNotificationContactChanged,
  189. object: nil,
  190. queue: nil) { [weak self] _ in
  191. self?.refreshInBg()
  192. }
  193. nc.addObserver(
  194. self,
  195. selector: #selector(applicationDidBecomeActive(_:)),
  196. name: UIApplication.didBecomeActiveNotification,
  197. object: nil)
  198. nc.addObserver(
  199. self,
  200. selector: #selector(applicationWillResignActive(_:)),
  201. name: UIApplication.willResignActiveNotification,
  202. object: nil)
  203. }
  204. private func removeObservers() {
  205. let nc = NotificationCenter.default
  206. // remove observers with a block
  207. if let msgChangedResultObserver = self.msgChangedSearchResultObserver {
  208. nc.removeObserver(msgChangedResultObserver)
  209. }
  210. if let msgChangedObserver = self.msgChangedObserver {
  211. nc.removeObserver(msgChangedObserver)
  212. }
  213. if let incomingMsgObserver = self.incomingMsgObserver {
  214. nc.removeObserver(incomingMsgObserver)
  215. }
  216. if let incomingMsgAnyAccountObserver = self.incomingMsgAnyAccountObserver {
  217. nc.removeObserver(incomingMsgAnyAccountObserver)
  218. }
  219. if let msgsNoticedObserver = self.msgsNoticedObserver {
  220. nc.removeObserver(msgsNoticedObserver)
  221. }
  222. if let chatModifiedObserver = self.chatModifiedObserver {
  223. nc.removeObserver(chatModifiedObserver)
  224. }
  225. if let contactsChangedObserver = self.contactsChangedObserver {
  226. nc.removeObserver(contactsChangedObserver)
  227. }
  228. if let connectivityChangedObserver = self.connectivityChangedObserver {
  229. nc.removeObserver(connectivityChangedObserver)
  230. }
  231. // remove non-block observers
  232. NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
  233. NotificationCenter.default.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
  234. }
  235. private func setupSubviews() {
  236. emptyStateLabel.addCenteredTo(parentView: view)
  237. navigationItem.backButtonTitle = isArchive ? String.localized("chat_archived_chats_title") : String.localized("pref_chats")
  238. }
  239. @objc
  240. public func onNavigationTitleTapped() {
  241. logger.debug("on navigation title tapped")
  242. let connectivityViewController = ConnectivityViewController(dcContext: dcContext)
  243. navigationController?.pushViewController(connectivityViewController, animated: true)
  244. }
  245. // MARK: - configuration
  246. private func configureTableView() {
  247. tableView.register(ContactCell.self, forCellReuseIdentifier: chatCellReuseIdentifier)
  248. tableView.register(ContactCell.self, forCellReuseIdentifier: deadDropCellReuseIdentifier)
  249. tableView.register(ContactCell.self, forCellReuseIdentifier: contactCellReuseIdentifier)
  250. tableView.rowHeight = ContactCell.cellHeight
  251. tableView.allowsMultipleSelectionDuringEditing = true
  252. }
  253. private var isInitial = true
  254. @objc func applicationDidBecomeActive(_ notification: NSNotification) {
  255. if navigationController?.visibleViewController == self {
  256. if !isInitial {
  257. startTimer()
  258. handleChatListUpdate()
  259. }
  260. isInitial = false
  261. }
  262. }
  263. private var inBgRefresh = false
  264. private var needsAnotherBgRefresh = false
  265. private func refreshInBg() {
  266. if inBgRefresh {
  267. needsAnotherBgRefresh = true
  268. } else {
  269. inBgRefresh = true
  270. DispatchQueue.global(qos: .userInteractive).async { [weak self] in
  271. // do at least one refresh, without inital delay
  272. // (refreshData() calls handleChatListUpdate() on main thread when done)
  273. self?.needsAnotherBgRefresh = false
  274. self?.viewModel?.refreshData()
  275. // do subsequent refreshes with a delay of 500ms
  276. while self?.needsAnotherBgRefresh != false {
  277. usleep(500000)
  278. self?.needsAnotherBgRefresh = false
  279. self?.viewModel?.refreshData()
  280. }
  281. self?.inBgRefresh = false
  282. }
  283. }
  284. }
  285. @objc func applicationWillResignActive(_ notification: NSNotification) {
  286. if navigationController?.visibleViewController == self {
  287. stopTimer()
  288. }
  289. }
  290. // MARK: - actions
  291. @objc func didPressNewChat() {
  292. showNewChatController()
  293. }
  294. @objc func cancelButtonPressed() {
  295. if tableView.isEditing {
  296. self.setLongTapEditing(false)
  297. } else {
  298. // cancel forwarding
  299. RelayHelper.shared.cancel()
  300. updateTitle()
  301. refreshInBg()
  302. }
  303. }
  304. override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  305. if previousTraitCollection?.preferredContentSizeCategory !=
  306. traitCollection.preferredContentSizeCategory {
  307. tableView.rowHeight = ContactCell.cellHeight
  308. }
  309. }
  310. private func quitSearch(animated: Bool) {
  311. searchController.searchBar.text = nil
  312. self.viewModel?.endSearch()
  313. searchController.dismiss(animated: animated) {
  314. self.tableView.scrollToTop()
  315. }
  316. }
  317. // MARK: - UITableViewDelegate + UITableViewDatasource
  318. override func numberOfSections(in tableView: UITableView) -> Int {
  319. return viewModel?.numberOfSections ?? 0
  320. }
  321. override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
  322. return viewModel?.numberOfRowsIn(section: section) ?? 0
  323. }
  324. override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  325. guard let viewModel = viewModel else {
  326. return UITableViewCell()
  327. }
  328. let cellData = viewModel.cellDataFor(section: indexPath.section, row: indexPath.row)
  329. switch cellData.type {
  330. case .chat(let chatData):
  331. let chatId = chatData.chatId
  332. if chatId == DC_CHAT_ID_ARCHIVED_LINK {
  333. archiveCell.actionTitle = dcContext.getChat(chatId: chatId).name
  334. archiveCell.backgroundColor = DcColors.chatBackgroundColor
  335. return archiveCell
  336. } else if let chatCell = tableView.dequeueReusableCell(withIdentifier: chatCellReuseIdentifier, for: indexPath) as? ContactCell {
  337. // default chatCell
  338. chatCell.updateCell(cellViewModel: cellData)
  339. chatCell.delegate = self
  340. return chatCell
  341. }
  342. case .contact:
  343. safe_assert(viewModel.searchActive)
  344. if let contactCell = tableView.dequeueReusableCell(withIdentifier: contactCellReuseIdentifier, for: indexPath) as? ContactCell {
  345. contactCell.updateCell(cellViewModel: cellData)
  346. return contactCell
  347. }
  348. case .profile:
  349. safe_fatalError("CellData type profile not allowed")
  350. }
  351. safe_fatalError("Could not find/dequeue or recycle UITableViewCell.")
  352. return UITableViewCell()
  353. }
  354. override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
  355. return viewModel?.titleForHeaderIn(section: section)
  356. }
  357. override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  358. if !tableView.isEditing {
  359. return indexPath
  360. }
  361. let cell = tableView.cellForRow(at: indexPath)
  362. return cell == archiveCell ? nil : indexPath
  363. }
  364. override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
  365. if tableView.isEditing,
  366. let viewModel = viewModel {
  367. editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows)
  368. if tableView.indexPathsForSelectedRows == nil {
  369. setLongTapEditing(false)
  370. } else {
  371. updateTitle()
  372. }
  373. }
  374. }
  375. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  376. guard let viewModel = viewModel else {
  377. tableView.deselectRow(at: indexPath, animated: false)
  378. return
  379. }
  380. if tableView.isEditing {
  381. editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows)
  382. updateTitle()
  383. return
  384. }
  385. let cellData = viewModel.cellDataFor(section: indexPath.section, row: indexPath.row)
  386. switch cellData.type {
  387. case .chat(let chatData):
  388. let chatId = chatData.chatId
  389. if chatId == DC_CHAT_ID_ARCHIVED_LINK {
  390. showArchive(animated: true)
  391. } else {
  392. showChat(chatId: chatId, highlightedMsg: chatData.highlightMsgId)
  393. }
  394. case .contact(let contactData):
  395. let contactId = contactData.contactId
  396. if let chatId = contactData.chatId {
  397. showChat(chatId: chatId)
  398. } else {
  399. self.askToChatWith(contactId: contactId)
  400. }
  401. case .profile:
  402. safe_fatalError("CellData type profile not allowed")
  403. }
  404. tableView.deselectRow(at: indexPath, animated: false)
  405. }
  406. override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
  407. guard let viewModel = viewModel else { return [] }
  408. guard let chatId = viewModel.chatIdFor(section: indexPath.section, row: indexPath.row) else {
  409. return []
  410. }
  411. if chatId==DC_CHAT_ID_ARCHIVED_LINK {
  412. return []
  413. // returning nil may result in a default delete action,
  414. // see https://forums.developer.apple.com/thread/115030
  415. }
  416. let chat = dcContext.getChat(chatId: chatId)
  417. let archived = chat.isArchived
  418. let archiveActionTitle: String = String.localized(archived ? "unarchive" : "archive")
  419. let archiveAction = UITableViewRowAction(style: .destructive, title: archiveActionTitle) { [weak self] _, _ in
  420. self?.viewModel?.archiveChatToggle(chatId: chatId)
  421. self?.setEditing(false, animated: true)
  422. }
  423. archiveAction.backgroundColor = UIColor.lightGray
  424. let pinned = chat.visibility==DC_CHAT_VISIBILITY_PINNED
  425. let pinAction = UITableViewRowAction(style: .destructive, title: String.localized(pinned ? "unpin" : "pin")) { [weak self] _, _ in
  426. self?.viewModel?.pinChatToggle(chatId: chat.id)
  427. self?.setEditing(false, animated: true)
  428. }
  429. pinAction.backgroundColor = UIColor.systemGreen
  430. let deleteAction = UITableViewRowAction(style: .normal, title: String.localized("delete")) { [weak self] _, _ in
  431. self?.showDeleteChatConfirmationAlert(chatId: chatId)
  432. }
  433. deleteAction.backgroundColor = UIColor.systemRed
  434. return [archiveAction, pinAction, deleteAction]
  435. }
  436. override func setEditing(_ editing: Bool, animated: Bool) {
  437. super.setEditing(editing, animated: animated)
  438. tableView.setEditing(editing, animated: animated)
  439. viewModel?.setEditing(editing)
  440. }
  441. func setLongTapEditing(_ editing: Bool, initialIndexPath: [IndexPath]? = nil) {
  442. setEditing(editing, animated: true)
  443. if editing {
  444. addEditingView()
  445. if let viewModel = viewModel {
  446. editingBar.showUnpinning = viewModel.hasOnlyPinnedChatsSelected(in: tableView.indexPathsForSelectedRows) ||
  447. viewModel.hasOnlyPinnedChatsSelected(in: initialIndexPath)
  448. }
  449. archiveCell.selectionStyle = .none
  450. } else {
  451. removeEditingView()
  452. archiveCell.selectionStyle = .default
  453. }
  454. updateTitle()
  455. }
  456. private func addEditingView() {
  457. guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
  458. let tabBarController = appDelegate.window?.rootViewController as? UITabBarController
  459. else { return }
  460. if !tabBarController.view.subviews.contains(editingBar) {
  461. tabBarController.tabBar.subviews.forEach { view in
  462. view.isHidden = true
  463. }
  464. tabBarController.view.addSubview(editingBar)
  465. editingConstraints = NSLayoutConstraintSet(top: editingBar.constraintAlignTopTo(tabBarController.tabBar),
  466. bottom: editingBar.constraintAlignBottomTo(tabBarController.tabBar),
  467. left: editingBar.constraintAlignLeadingTo(tabBarController.tabBar),
  468. right: editingBar.constraintAlignTrailingTo(tabBarController.tabBar))
  469. editingConstraints?.activate()
  470. }
  471. }
  472. private func removeEditingView() {
  473. guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
  474. let tabBarController = appDelegate.window?.rootViewController as? UITabBarController
  475. else { return }
  476. editingBar.removeFromSuperview()
  477. editingConstraints?.deactivate()
  478. editingConstraints = nil
  479. tabBarController.tabBar.subviews.forEach { view in
  480. view.isHidden = false
  481. }
  482. }
  483. private func updateAccountButton() {
  484. accountButtonAvatar.setUnreadMessageCount(getUnreadCounterOfOtherAccounts())
  485. let contact = dcContext.getContact(id: Int(DC_CONTACT_ID_SELF))
  486. let title = dcContext.displayname ?? dcContext.addr ?? ""
  487. accountButtonAvatar.setColor(contact.color)
  488. accountButtonAvatar.setName(title)
  489. if let image = contact.profileImage {
  490. accountButtonAvatar.setImage(image)
  491. }
  492. }
  493. private func getUnreadCounterOfOtherAccounts() -> Int {
  494. var unreadCount = 0
  495. let selectedAccountId = dcAccounts.getSelected().id
  496. for accountId in dcAccounts.getAll() {
  497. if accountId == selectedAccountId {
  498. continue
  499. }
  500. unreadCount += dcAccounts.get(id: accountId).getFreshMessages().count
  501. }
  502. return unreadCount
  503. }
  504. @objc private func accountButtonTapped() {
  505. let viewController = AccountSwitchViewController(dcAccounts: dcAccounts)
  506. let accountSwitchNavigationController = UINavigationController(rootViewController: viewController)
  507. if #available(iOS 15.0, *) {
  508. if let sheet = accountSwitchNavigationController.sheetPresentationController {
  509. sheet.detents = [.medium()]
  510. sheet.preferredCornerRadius = 20
  511. }
  512. }
  513. self.present(accountSwitchNavigationController, animated: true)
  514. }
  515. // MARK: updates
  516. private func updateTitle() {
  517. titleView.accessibilityHint = String.localized("a11y_connectivity_hint")
  518. if RelayHelper.shared.isForwarding() {
  519. // multi-select is not allowed during forwarding
  520. titleView.text = String.localized("forward_to")
  521. if !isArchive {
  522. navigationItem.setLeftBarButton(cancelButton, animated: true)
  523. }
  524. } else if isArchive {
  525. titleView.text = String.localized("chat_archived_chats_title")
  526. if !handleMultiSelectionTitle() {
  527. navigationItem.setLeftBarButton(nil, animated: true)
  528. }
  529. } else {
  530. titleView.text = DcUtils.getConnectivityString(dcContext: dcContext, connectedString: String.localized("pref_chats"))
  531. if !handleMultiSelectionTitle() {
  532. navigationItem.setLeftBarButton(nil, animated: true)
  533. navigationItem.setRightBarButton(newButton, animated: true)
  534. if dcContext.getConnectivity() >= DC_CONNECTIVITY_CONNECTED {
  535. titleView.accessibilityHint = "\(String.localized("connectivity_connected")): \(String.localized("a11y_connectivity_hint"))"
  536. }
  537. }
  538. navigationItem.setLeftBarButton(accountButton, animated: false)
  539. updateAccountButton()
  540. }
  541. titleView.isUserInteractionEnabled = !tableView.isEditing
  542. titleView.sizeToFit()
  543. }
  544. func handleMultiSelectionTitle() -> Bool {
  545. if !tableView.isEditing {
  546. return false
  547. }
  548. titleView.accessibilityHint = nil
  549. let cnt = tableView.indexPathsForSelectedRows?.count ?? 1
  550. titleView.text = String.localized(stringID: "n_selected", count: cnt)
  551. navigationItem.setLeftBarButton(cancelButton, animated: true)
  552. navigationItem.setRightBarButton(nil, animated: true)
  553. return true
  554. }
  555. func handleChatListUpdate() {
  556. if let viewModel = viewModel, viewModel.isEditing {
  557. viewModel.setPendingChatListUpdate()
  558. return
  559. }
  560. if Thread.isMainThread {
  561. tableView.reloadData()
  562. handleEmptyStateLabel()
  563. } else {
  564. DispatchQueue.main.async { [weak self] in
  565. guard let self = self else { return }
  566. self.tableView.reloadData()
  567. self.handleEmptyStateLabel()
  568. }
  569. }
  570. }
  571. private func handleEmptyStateLabel() {
  572. if let emptySearchText = viewModel?.emptySearchText {
  573. let text = String.localizedStringWithFormat(
  574. String.localized("search_no_result_for_x"),
  575. emptySearchText
  576. )
  577. emptyStateLabel.text = text
  578. emptyStateLabel.isHidden = false
  579. } else if isArchive && (viewModel?.numberOfRowsIn(section: 0) ?? 0) == 0 {
  580. emptyStateLabel.text = String.localized("archive_empty_hint")
  581. emptyStateLabel.isHidden = false
  582. } else {
  583. emptyStateLabel.text = nil
  584. emptyStateLabel.isHidden = true
  585. }
  586. }
  587. private func startTimer() {
  588. // check if the timer is not yet started
  589. stopTimer()
  590. timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
  591. guard let self = self,
  592. let appDelegate = UIApplication.shared.delegate as? AppDelegate
  593. else { return }
  594. if appDelegate.appIsInForeground() {
  595. self.handleChatListUpdate()
  596. } else {
  597. logger.warning("startTimer() must not be executed in background")
  598. }
  599. }
  600. }
  601. private func stopTimer() {
  602. // check if the timer is not already stopped
  603. if let timer = timer {
  604. timer.invalidate()
  605. }
  606. timer = nil
  607. }
  608. public func handleMailto(askToChat: Bool = true) {
  609. if let mailtoAddress = RelayHelper.shared.mailtoAddress {
  610. // FIXME: the line below should work
  611. // var contactId = dcContext.lookupContactIdByAddress(mailtoAddress)
  612. // workaround:
  613. let contacts: [Int] = dcContext.getContacts(flags: DC_GCL_ADD_SELF, queryString: mailtoAddress)
  614. let index = contacts.firstIndex(where: { dcContext.getContact(id: $0).email == mailtoAddress }) ?? -1
  615. var contactId = 0
  616. if index >= 0 {
  617. contactId = contacts[index]
  618. }
  619. if contactId != 0 && dcContext.getChatIdByContactId(contactId: contactId) != 0 {
  620. showChat(chatId: dcContext.getChatIdByContactId(contactId: contactId), animated: false)
  621. } else if askToChat {
  622. askToChatWith(address: mailtoAddress)
  623. } else {
  624. // Attention: we should have already asked in a different view controller!
  625. createAndShowNewChat(contactId: 0, email: mailtoAddress)
  626. }
  627. }
  628. }
  629. // MARK: - alerts
  630. private func showDeleteChatConfirmationAlert(chatId: Int) {
  631. let alert = UIAlertController(
  632. title: nil,
  633. message: String.localizedStringWithFormat(String.localized("ask_delete_named_chat"), dcContext.getChat(chatId: chatId).name),
  634. preferredStyle: .safeActionSheet
  635. )
  636. alert.addAction(UIAlertAction(title: String.localized("menu_delete_chat"), style: .destructive, handler: { _ in
  637. self.deleteChat(chatId: chatId, animated: true)
  638. }))
  639. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  640. self.present(alert, animated: true, completion: nil)
  641. }
  642. private func showDeleteMultipleChatConfirmationAlert() {
  643. let selected = tableView.indexPathsForSelectedRows?.count ?? 0
  644. if selected == 0 {
  645. return
  646. }
  647. let alert = UIAlertController(
  648. title: nil,
  649. message: String.localized(stringID: "ask_delete_chat", count: selected),
  650. preferredStyle: .safeActionSheet
  651. )
  652. alert.addAction(UIAlertAction(title: String.localized("delete"), style: .destructive, handler: { [weak self] _ in
  653. guard let self = self, let viewModel = self.viewModel else { return }
  654. viewModel.deleteChats(indexPaths: self.tableView.indexPathsForSelectedRows)
  655. self.setLongTapEditing(false)
  656. }))
  657. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  658. self.present(alert, animated: true, completion: nil)
  659. }
  660. private func askToChatWith(address: String, contactId: Int = 0) {
  661. let alert = UIAlertController(title: String.localizedStringWithFormat(String.localized("ask_start_chat_with"), address),
  662. message: nil,
  663. preferredStyle: .safeActionSheet)
  664. alert.addAction(UIAlertAction(title: String.localized("start_chat"), style: .default, handler: { [weak self] _ in
  665. guard let self = self else { return }
  666. self.createAndShowNewChat(contactId: contactId, email: address)
  667. }))
  668. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: { _ in
  669. if RelayHelper.shared.isMailtoHandling() {
  670. RelayHelper.shared.finishMailto()
  671. }
  672. }))
  673. present(alert, animated: true, completion: nil)
  674. }
  675. private func createAndShowNewChat(contactId: Int, email: String) {
  676. var contactId = contactId
  677. if contactId == 0 {
  678. contactId = self.dcContext.createContact(name: nil, email: email)
  679. }
  680. self.showNewChat(contactId: contactId)
  681. }
  682. private func askToChatWith(contactId: Int) {
  683. let dcContact = dcContext.getContact(id: contactId)
  684. askToChatWith(address: dcContact.nameNAddr, contactId: contactId)
  685. }
  686. private func deleteChat(chatId: Int, animated: Bool) {
  687. guard let viewModel = viewModel else { return }
  688. if !animated {
  689. viewModel.deleteChat(chatId: chatId)
  690. refreshInBg()
  691. return
  692. }
  693. if viewModel.searchActive {
  694. viewModel.deleteChat(chatId: chatId)
  695. viewModel.refreshData()
  696. viewModel.updateSearchResults(for: searchController)
  697. return
  698. }
  699. viewModel.deleteChat(chatId: chatId)
  700. }
  701. // MARK: - coordinator
  702. private func showNewChatController() {
  703. let newChatVC = NewChatViewController(dcContext: dcContext)
  704. navigationController?.pushViewController(newChatVC, animated: true)
  705. }
  706. func showChat(chatId: Int, highlightedMsg: Int? = nil, animated: Bool = true) {
  707. if searchController.isActive {
  708. searchController.searchBar.resignFirstResponder()
  709. }
  710. let chatVC = ChatViewController(dcContext: dcContext, chatId: chatId, highlightedMsg: highlightedMsg)
  711. navigationController?.pushViewController(chatVC, animated: animated)
  712. }
  713. public func showArchive(animated: Bool) {
  714. let controller = ChatListController(dcContext: dcContext, dcAccounts: dcAccounts, isArchive: true)
  715. navigationController?.pushViewController(controller, animated: animated)
  716. }
  717. private func showNewChat(contactId: Int) {
  718. let chatId = dcContext.createChatByContactId(contactId: contactId)
  719. showChat(chatId: Int(chatId))
  720. }
  721. }
  722. // MARK: - uisearchbardelegate
  723. extension ChatListController: UISearchBarDelegate {
  724. func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
  725. viewModel?.beginSearch()
  726. setLongTapEditing(false)
  727. return true
  728. }
  729. func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
  730. // searchBar will be set to "" by system
  731. viewModel?.endSearch()
  732. DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
  733. self.tableView.scrollToTop()
  734. }
  735. }
  736. func searchBar(_ searchBar: UISearchBar, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
  737. tableView.scrollToTop()
  738. return true
  739. }
  740. }
  741. extension ChatListController: ContactCellDelegate {
  742. func onLongTap(at indexPath: IndexPath) {
  743. if let searchActive = viewModel?.searchActive,
  744. !searchActive,
  745. !RelayHelper.shared.isForwarding(),
  746. !tableView.isEditing {
  747. UIImpactFeedbackGenerator(style: .medium).impactOccurred()
  748. setLongTapEditing(true, initialIndexPath: [indexPath])
  749. tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
  750. }
  751. }
  752. }
  753. extension ChatListController: ChatListEditingBarDelegate {
  754. func onPinButtonPressed() {
  755. viewModel?.pinChatsToggle(indexPaths: tableView.indexPathsForSelectedRows)
  756. setLongTapEditing(false)
  757. }
  758. func onDeleteButtonPressed() {
  759. showDeleteMultipleChatConfirmationAlert()
  760. }
  761. func onArchiveButtonPressed() {
  762. viewModel?.archiveChatsToggle(indexPaths: tableView.indexPathsForSelectedRows)
  763. setLongTapEditing(false)
  764. }
  765. }