mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			486 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			486 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionUIKit
 | |
| import SessionMessagingKit
 | |
| import SignalUtilitiesKit
 | |
| 
 | |
| class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
 | |
|     private static let loadingHeaderHeight: CGFloat = 40
 | |
|     
 | |
|     private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
 | |
|     private var hasLoadedInitialThreadData: Bool = false
 | |
|     private var isLoadingMore: Bool = false
 | |
|     private var isAutoLoadingNextPage: Bool = false
 | |
|     private var viewHasAppeared: Bool = false
 | |
|     
 | |
|     // MARK: - SessionUtilRespondingViewController
 | |
|     
 | |
|     let isConversationList: Bool = true
 | |
|     
 | |
|     // MARK: - Intialization
 | |
|     
 | |
|     init() {
 | |
|         Storage.shared.addObserver(viewModel.pagedDataObserver)
 | |
|         
 | |
|         super.init(nibName: nil, bundle: nil)
 | |
|     }
 | |
| 
 | |
|     required init?(coder: NSCoder) {
 | |
|         preconditionFailure("Use init() instead.")
 | |
|     }
 | |
|     
 | |
|     deinit {
 | |
|         NotificationCenter.default.removeObserver(self)
 | |
|     }
 | |
|     
 | |
|     // MARK: - UI
 | |
|     
 | |
|     private lazy var loadingConversationsLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.text = "LOADING_CONVERSATIONS".localized()
 | |
|         result.themeTextColor = .textSecondary
 | |
|         result.textAlignment = .center
 | |
|         result.numberOfLines = 0
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var tableView: UITableView = {
 | |
|         let result: UITableView = UITableView()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.separatorStyle = .none
 | |
|         result.themeBackgroundColor = .clear
 | |
|         result.showsVerticalScrollIndicator = false
 | |
|         result.contentInset = UIEdgeInsets(
 | |
|             top: 0,
 | |
|             left: 0,
 | |
|             bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
 | |
|             right: 0
 | |
|         )
 | |
|         result.register(view: FullConversationCell.self)
 | |
|         result.dataSource = self
 | |
|         result.delegate = self
 | |
|         
 | |
|         if #available(iOS 15.0, *) {
 | |
|             result.sectionHeaderTopPadding = 0
 | |
|         }
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var emptyStateLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.isUserInteractionEnabled = false
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
 | |
|         result.themeTextColor = .textSecondary
 | |
|         result.textAlignment = .center
 | |
|         result.numberOfLines = 0
 | |
|         result.isHidden = true
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var fadeView: GradientView = {
 | |
|         let result: GradientView = GradientView()
 | |
|         result.themeBackgroundGradient = [
 | |
|             .value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
 | |
|             .backgroundPrimary,
 | |
|             .backgroundPrimary,
 | |
|             .backgroundPrimary,
 | |
|             .backgroundPrimary
 | |
|         ]
 | |
|         result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     private lazy var clearAllButton: SessionButton = {
 | |
|         let result: SessionButton = SessionButton(style: .destructive, size: .large)
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
 | |
|         result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
 | |
|         result.accessibilityIdentifier = "Clear all"
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     // MARK: - Lifecycle
 | |
| 
 | |
|     override func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
| 
 | |
|         ViewControllerUtilities.setUpDefaultSessionStyle(
 | |
|             for: self,
 | |
|                title: "MESSAGE_REQUESTS_TITLE".localized(),
 | |
|                hasCustomBackButton: false
 | |
|         )
 | |
| 
 | |
|         // Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
 | |
|         // the dataSource has the correct data)
 | |
|         view.addSubview(loadingConversationsLabel)
 | |
|         view.addSubview(tableView)
 | |
|         view.addSubview(emptyStateLabel)
 | |
|         view.addSubview(fadeView)
 | |
|         view.addSubview(clearAllButton)
 | |
|         setupLayout()
 | |
| 
 | |
|         // Notifications
 | |
|         NotificationCenter.default.addObserver(
 | |
|             self,
 | |
|             selector: #selector(applicationDidBecomeActive(_:)),
 | |
|             name: UIApplication.didBecomeActiveNotification,
 | |
|             object: nil
 | |
|         )
 | |
|         NotificationCenter.default.addObserver(
 | |
|             self,
 | |
|             selector: #selector(applicationDidResignActive(_:)),
 | |
|             name: UIApplication.didEnterBackgroundNotification, object: nil
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     override func viewWillAppear(_ animated: Bool) {
 | |
|         super.viewWillAppear(animated)
 | |
|         
 | |
|         startObservingChanges()
 | |
|     }
 | |
|     
 | |
|     override func viewDidAppear(_ animated: Bool) {
 | |
|         super.viewDidAppear(animated)
 | |
|         
 | |
|         self.viewHasAppeared = true
 | |
|         self.autoLoadNextPageIfNeeded()
 | |
|     }
 | |
|     
 | |
|     override func viewWillDisappear(_ animated: Bool) {
 | |
|         super.viewWillDisappear(animated)
 | |
|         
 | |
|         stopObservingChanges()
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidBecomeActive(_ notification: Notification) {
 | |
|         /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
 | |
|         DispatchQueue.main.async { [weak self] in
 | |
|             self?.startObservingChanges(didReturnFromBackground: true)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidResignActive(_ notification: Notification) {
 | |
|         stopObservingChanges()
 | |
|     }
 | |
| 
 | |
|     // MARK: - Layout
 | |
| 
 | |
|     private func setupLayout() {
 | |
|         NSLayoutConstraint.activate([
 | |
|             loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
 | |
|             loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
 | |
|             loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
 | |
|             
 | |
|             tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
 | |
|             tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
 | |
|             tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
 | |
|             tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
 | |
| 
 | |
|             emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
 | |
|             emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
 | |
|             emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
 | |
|             emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
 | |
|             
 | |
|             fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
 | |
|             fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
 | |
|             fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
 | |
|             
 | |
|             clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
 | |
|             clearAllButton.bottomAnchor.constraint(
 | |
|                 equalTo: view.safeAreaLayoutGuide.bottomAnchor,
 | |
|                 constant: -Values.smallSpacing
 | |
|             ),
 | |
|             clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
 | |
|         ])
 | |
|     }
 | |
|     
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     private func startObservingChanges(didReturnFromBackground: Bool = false) {
 | |
|         self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
 | |
|             self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
 | |
|         }
 | |
|         
 | |
|         // Note: When returning from the background we could have received notifications but the
 | |
|         // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
 | |
|         // data to ensure everything is up to date
 | |
|         if didReturnFromBackground {
 | |
|             self.viewModel.pagedDataObserver?.reload()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func stopObservingChanges() {
 | |
|         self.viewModel.onThreadChange = nil
 | |
|     }
 | |
|     
 | |
|     private func handleThreadUpdates(
 | |
|         _ updatedData: [MessageRequestsViewModel.SectionModel],
 | |
|         changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
 | |
|         initialLoad: Bool = false
 | |
|     ) {
 | |
|         // Ensure the first load runs without animations (if we don't do this the cells will animate
 | |
|         // in from a frame of CGRect.zero)
 | |
|         guard hasLoadedInitialThreadData else {
 | |
|             UIView.performWithoutAnimation {
 | |
|                 // Hide the 'loading conversations' label (now that we have received conversation data)
 | |
|                 loadingConversationsLabel.isHidden = true
 | |
|                 
 | |
|                 // Show the empty state if there is no data
 | |
|                 clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
 | |
|                 emptyStateLabel.isHidden = !clearAllButton.isHidden
 | |
|                 
 | |
|                 // Update the content
 | |
|                 viewModel.updateThreadData(updatedData)
 | |
|                 tableView.reloadData()
 | |
|                 hasLoadedInitialThreadData = true
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Hide the 'loading conversations' label (now that we have received conversation data)
 | |
|         loadingConversationsLabel.isHidden = true
 | |
|         
 | |
|         // Show the empty state if there is no data
 | |
|         clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
 | |
|         emptyStateLabel.isHidden = !clearAllButton.isHidden
 | |
|         
 | |
|         CATransaction.begin()
 | |
|         CATransaction.setCompletionBlock { [weak self] in
 | |
|             // Complete page loading
 | |
|             self?.isLoadingMore = false
 | |
|             self?.autoLoadNextPageIfNeeded()
 | |
|         }
 | |
|         
 | |
|         // Reload the table content (animate changes after the first load)
 | |
|         tableView.reload(
 | |
|             using: changeset,
 | |
|             deleteSectionsAnimation: .none,
 | |
|             insertSectionsAnimation: .none,
 | |
|             reloadSectionsAnimation: .none,
 | |
|             deleteRowsAnimation: .bottom,
 | |
|             insertRowsAnimation: .top,
 | |
|             reloadRowsAnimation: .none,
 | |
|             interrupt: { $0.changeCount > 100 }    // Prevent too many changes from causing performance issues
 | |
|         ) { [weak self] updatedData in
 | |
|             self?.viewModel.updateThreadData(updatedData)
 | |
|         }
 | |
|         
 | |
|         CATransaction.commit()
 | |
|     }
 | |
|     
 | |
|     private func autoLoadNextPageIfNeeded() {
 | |
|         guard
 | |
|             self.hasLoadedInitialThreadData &&
 | |
|             !self.isAutoLoadingNextPage &&
 | |
|             !self.isLoadingMore
 | |
|         else { return }
 | |
|         
 | |
|         self.isAutoLoadingNextPage = true
 | |
|         
 | |
|         DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
 | |
|             self?.isAutoLoadingNextPage = false
 | |
|             
 | |
|             // Note: We sort the headers as we want to prioritise loading newer pages over older ones
 | |
|             let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
 | |
|                 .enumerated()
 | |
|                 .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
 | |
|                 .defaulting(to: [])
 | |
|             let shouldLoadMore: Bool = sections
 | |
|                 .contains { section, headerRect in
 | |
|                     section == .loadMore &&
 | |
|                     headerRect != .zero &&
 | |
|                     (self?.tableView.bounds.contains(headerRect) == true)
 | |
|                 }
 | |
|             
 | |
|             guard shouldLoadMore else { return }
 | |
|             
 | |
|             self?.isLoadingMore = true
 | |
|             
 | |
|             DispatchQueue.global(qos: .userInitiated).async { [weak self] in
 | |
|                 self?.viewModel.pagedDataObserver?.load(.pageAfter)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - UITableViewDataSource
 | |
| 
 | |
|     func numberOfSections(in tableView: UITableView) -> Int {
 | |
|         return viewModel.threadData.count
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
 | |
|         let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
 | |
|         
 | |
|         return section.elements.count
 | |
|     }
 | |
| 
 | |
|     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
 | |
|         let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .threads:
 | |
|                 let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
 | |
|                 let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
 | |
|                 cell.accessibilityIdentifier = "Message request"
 | |
|                 cell.isAccessibilityElement = true
 | |
|                 cell.update(with: threadViewModel)
 | |
|                 return cell
 | |
|                 
 | |
|             default: preconditionFailure("Other sections should have no content")
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
 | |
|         let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .loadMore:
 | |
|                 let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
 | |
|                 loadingIndicator.themeTintColor = .textPrimary
 | |
|                 loadingIndicator.alpha = 0.5
 | |
|                 loadingIndicator.startAnimating()
 | |
|                 
 | |
|                 let view: UIView = UIView()
 | |
|                 view.addSubview(loadingIndicator)
 | |
|                 loadingIndicator.center(in: view)
 | |
|                 
 | |
|                 return view
 | |
|             
 | |
|             default: return nil
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - UITableViewDelegate
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
 | |
|         let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
 | |
|             default: return 0
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
 | |
|         guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
 | |
|         
 | |
|         let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .loadMore:
 | |
|                 self.isLoadingMore = true
 | |
|                 
 | |
|                 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
 | |
|                     self?.viewModel.pagedDataObserver?.load(.pageAfter)
 | |
|                 }
 | |
|                 
 | |
|             default: break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
 | |
|         tableView.deselectRow(at: indexPath, animated: true)
 | |
|         
 | |
|         let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .threads:
 | |
|                 let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
 | |
|                 let conversationVC: ConversationVC = ConversationVC(
 | |
|                     threadId: threadViewModel.threadId,
 | |
|                     threadVariant: threadViewModel.threadVariant
 | |
|                 )
 | |
|                 self.navigationController?.pushViewController(conversationVC, animated: true)
 | |
|                 
 | |
|             default: break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
 | |
|         return true
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
 | |
|         UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
 | |
|         UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
 | |
|         let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
 | |
|         let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .threads:
 | |
|                 return UIContextualAction.configuration(
 | |
|                     for: UIContextualAction.generateSwipeActions(
 | |
|                         [
 | |
|                             (threadViewModel.threadVariant != .contact ? nil : .block),
 | |
|                             .delete
 | |
|                         ].compactMap { $0 },
 | |
|                         for: .trailing,
 | |
|                         indexPath: indexPath,
 | |
|                         tableView: tableView,
 | |
|                         threadViewModel: threadViewModel,
 | |
|                         viewController: self
 | |
|                     )
 | |
|                 )
 | |
|                 
 | |
|             default: return nil
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Interaction
 | |
|     
 | |
|     @objc private func clearAllTapped() {
 | |
|         guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let contactThreadIds: [String] = (viewModel.threadData
 | |
|             .first { $0.model == .threads }?
 | |
|             .elements
 | |
|             .filter { $0.threadVariant == .contact }
 | |
|             .map { $0.threadId })
 | |
|             .defaulting(to: [])
 | |
|         let groupThreadIds: [String] = (viewModel.threadData
 | |
|             .first { $0.model == .threads }?
 | |
|             .elements
 | |
|             .filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
 | |
|             .map { $0.threadId })
 | |
|             .defaulting(to: [])
 | |
|         let alertVC: UIAlertController = UIAlertController(
 | |
|             title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
 | |
|             message: nil,
 | |
|             preferredStyle: .actionSheet
 | |
|         )
 | |
|         alertVC.addAction(UIAlertAction(
 | |
|             title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
 | |
|             style: .destructive
 | |
|         ) { _ in
 | |
|             MessageRequestsViewModel.clearAllRequests(
 | |
|                 contactThreadIds: contactThreadIds,
 | |
|                 groupThreadIds: groupThreadIds
 | |
|             )
 | |
|         })
 | |
|         alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
 | |
|         
 | |
|         Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
 | |
|         self.present(alertVC, animated: true, completion: nil)
 | |
|     }
 | |
| }
 |