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.
		
		
		
		
		
			
		
			
				
	
	
		
			605 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			605 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import Combine
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionUIKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalUtilitiesKit
 | |
| 
 | |
| protocol SessionViewModelAccessible {
 | |
|     var viewModelType: AnyObject.Type { get }
 | |
| }
 | |
| 
 | |
| class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible where ViewModel: (SessionTableViewModel & ObservableTableSource) {
 | |
|     typealias Section = ViewModel.Section
 | |
|     typealias TableItem = ViewModel.TableItem
 | |
|     typealias SectionModel = ViewModel.SectionModel
 | |
|     
 | |
|     private let viewModel: ViewModel
 | |
|     private var hasLoadedInitialTableData: Bool = false
 | |
|     private var isLoadingMore: Bool = false
 | |
|     private var isAutoLoadingNextPage: Bool = false
 | |
|     private var viewHasAppeared: Bool = false
 | |
|     private var dataStreamJustFailed: Bool = false
 | |
|     private var dataChangeCancellable: AnyCancellable?
 | |
|     private var disposables: Set<AnyCancellable> = Set()
 | |
|     private var onFooterTap: (() -> ())?
 | |
|     
 | |
|     public var viewModelType: AnyObject.Type { return type(of: viewModel) }
 | |
|     
 | |
|     // MARK: - Components
 | |
|     
 | |
|     private lazy var titleView: SessionTableViewTitleView = SessionTableViewTitleView()
 | |
|     
 | |
|     private lazy var tableView: UITableView = {
 | |
|         let result: UITableView = UITableView()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.separatorStyle = .none
 | |
|         result.themeBackgroundColor = .clear
 | |
|         result.showsVerticalScrollIndicator = false
 | |
|         result.showsHorizontalScrollIndicator = false
 | |
|         result.register(view: SessionCell.self)
 | |
|         result.register(view: FullConversationCell.self)
 | |
|         result.registerHeaderFooterView(view: SessionHeaderView.self)
 | |
|         result.registerHeaderFooterView(view: SessionFooterView.self)
 | |
|         result.dataSource = self
 | |
|         result.delegate = self
 | |
|         result.sectionHeaderTopPadding = 0
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var initialLoadLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.isUserInteractionEnabled = false
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         result.themeTextColor = .textSecondary
 | |
|         result.text = viewModel.initialLoadMessage
 | |
|         result.textAlignment = .center
 | |
|         result.numberOfLines = 0
 | |
|         result.isHidden = (viewModel.initialLoadMessage == nil)
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var emptyStateLabel: UILabel = {
 | |
|         let result: UILabel = UILabel()
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.isUserInteractionEnabled = false
 | |
|         result.font = .systemFont(ofSize: Values.smallFontSize)
 | |
|         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))
 | |
|         result.isHidden = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var footerButton: SessionButton = {
 | |
|         let result: SessionButton = SessionButton(style: .bordered, size: .medium)
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.addTarget(self, action: #selector(footerButtonTapped), for: .touchUpInside)
 | |
|         result.isHidden = true
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     // MARK: - Initialization
 | |
|     
 | |
|     init(viewModel: ViewModel) {
 | |
|         self.viewModel = viewModel
 | |
|         
 | |
|         (viewModel as? (any PagedObservationSource))?.didInit(using: viewModel.dependencies)
 | |
|         
 | |
|         super.init(nibName: nil, bundle: nil)
 | |
|     }
 | |
|     
 | |
|     required init?(coder: NSCoder) {
 | |
|         fatalError("init(coder:) has not been implemented")
 | |
|     }
 | |
|     
 | |
|     deinit {
 | |
|         NotificationCenter.default.removeObserver(self)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Lifecycle
 | |
|     
 | |
|     override func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
|         
 | |
|         navigationItem.titleView = titleView
 | |
|         titleView.update(title: self.viewModel.title, subtitle: self.viewModel.subtitle)
 | |
|         
 | |
|         view.themeBackgroundColor = .backgroundPrimary
 | |
|         view.addSubview(tableView)
 | |
|         view.addSubview(initialLoadLabel)
 | |
|         view.addSubview(emptyStateLabel)
 | |
|         view.addSubview(fadeView)
 | |
|         view.addSubview(footerButton)
 | |
|         
 | |
|         setupLayout()
 | |
|         setupBinding()
 | |
|         
 | |
|         // 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)
 | |
|         
 | |
|         viewHasAppeared = true
 | |
|         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()
 | |
|     }
 | |
|     
 | |
|     private func setupLayout() {
 | |
|         tableView.pin(to: view)
 | |
|         
 | |
|         initialLoadLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
 | |
|         initialLoadLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
 | |
|         initialLoadLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
 | |
|         
 | |
|         emptyStateLabel.pin(.top, to: .top, of: self.view, withInset: Values.massiveSpacing)
 | |
|         emptyStateLabel.pin(.leading, to: .leading, of: self.view, withInset: Values.mediumSpacing)
 | |
|         emptyStateLabel.pin(.trailing, to: .trailing, of: self.view, withInset: -Values.mediumSpacing)
 | |
|         
 | |
|         fadeView.pin(.leading, to: .leading, of: self.view)
 | |
|         fadeView.pin(.trailing, to: .trailing, of: self.view)
 | |
|         fadeView.pin(.bottom, to: .bottom, of: self.view)
 | |
|         
 | |
|         footerButton.center(.horizontal, in: self.view)
 | |
|         footerButton.pin(.bottom, to: .bottom, of: self.view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     private func startObservingChanges(didReturnFromBackground: Bool = false) {
 | |
|         // Start observing for data changes
 | |
|         dataChangeCancellable = viewModel.tableDataPublisher
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sink(
 | |
|                 receiveCompletion: { [weak self] result in
 | |
|                     switch result {
 | |
|                         case .failure(let error):
 | |
|                         let title: String = (self?.viewModel.title ?? "unknown".localized())
 | |
|                             
 | |
|                             // If we got an error then try to restart the stream once, otherwise log the error
 | |
|                             guard self?.dataStreamJustFailed == false else {
 | |
|                                 SNLog("Unable to recover database stream in '\(title)' settings with error: \(error)")
 | |
|                                 return
 | |
|                             }
 | |
|                             
 | |
|                             SNLog("Atempting recovery for database stream in '\(title)' settings with error: \(error)")
 | |
|                             self?.dataStreamJustFailed = true
 | |
|                             self?.startObservingChanges(didReturnFromBackground: didReturnFromBackground)
 | |
|                             
 | |
|                         case .finished: break
 | |
|                     }
 | |
|                 },
 | |
|                 receiveValue: { [weak self] updatedData in
 | |
|                     self?.dataStreamJustFailed = false
 | |
|                     self?.handleDataUpdates(updatedData)
 | |
|                 }
 | |
|             )
 | |
|         
 | |
|         // Some viewModel's may need to run custom logic after returning from the background so trigger that here
 | |
|         if didReturnFromBackground { viewModel.didReturnFromBackground() }
 | |
|     }
 | |
|     
 | |
|     private func stopObservingChanges() {
 | |
|         // Stop observing database changes
 | |
|         dataChangeCancellable?.cancel()
 | |
|     }
 | |
|     
 | |
|     private func handleDataUpdates(_ updatedData: [SectionModel]) {
 | |
|         // Determine if we have any items for the empty state
 | |
|         let itemCount: Int = updatedData
 | |
|             .map { $0.elements.count }
 | |
|             .reduce(0, +)
 | |
|         
 | |
|         // Ensure the reloads run without animations (if we don't do this the cells will animate
 | |
|         // in from a frame of CGRect.zero on at least the first load)
 | |
|         UIView.performWithoutAnimation {
 | |
|             // Update the initial/empty state
 | |
|             initialLoadLabel.isHidden = true
 | |
|             emptyStateLabel.isHidden = (itemCount > 0)
 | |
|             
 | |
|             // Update the content
 | |
|             viewModel.updateTableData(updatedData)
 | |
|             tableView.reloadData()
 | |
|             hasLoadedInitialTableData = true
 | |
|             
 | |
|             // Complete page loading
 | |
|             isLoadingMore = false
 | |
|             autoLoadNextPageIfNeeded()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func autoLoadNextPageIfNeeded() {
 | |
|         guard
 | |
|             self.hasLoadedInitialTableData &&
 | |
|             !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: [(Section, CGRect)] = (self?.viewModel.tableData
 | |
|                 .enumerated()
 | |
|                 .map { index, section in
 | |
|                     (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero))
 | |
|                 })
 | |
|                 .defaulting(to: [])
 | |
|             let shouldLoadMore: Bool = sections
 | |
|                 .contains { section, headerRect in
 | |
|                     section.style == .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 as? (any PagedObservationSource))?.loadPageAfter()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Binding
 | |
| 
 | |
|     private func setupBinding() {
 | |
|         (viewModel as? (any NavigationItemSource))?.setupBindings(
 | |
|             viewController: self,
 | |
|             disposables: &disposables
 | |
|         )
 | |
|         (viewModel as? (any NavigatableStateHolder))?.navigatableState.setupBindings(
 | |
|             viewController: self,
 | |
|             disposables: &disposables
 | |
|         )
 | |
|         
 | |
|         (viewModel as? ErasedEditableStateHolder)?.isEditing
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sink { [weak self, weak tableView] isEditing in
 | |
|                 UIView.animate(withDuration: 0.25) {
 | |
|                     self?.setEditing(isEditing, animated: true)
 | |
|                     
 | |
|                     tableView?.visibleCells
 | |
|                         .compactMap { $0 as? SessionCell }
 | |
|                         .filter { $0.interactionMode == .editable || $0.interactionMode == .alwaysEditing }
 | |
|                         .enumerated()
 | |
|                         .forEach { index, cell in
 | |
|                             cell.update(
 | |
|                                 isEditing: (isEditing || cell.interactionMode == .alwaysEditing),
 | |
|                                 becomeFirstResponder: (
 | |
|                                     isEditing &&
 | |
|                                     index == 0 &&
 | |
|                                     cell.interactionMode != .alwaysEditing
 | |
|                                 ),
 | |
|                                 animated: true
 | |
|                             )
 | |
|                         }
 | |
|                     
 | |
|                     tableView?.beginUpdates()
 | |
|                     tableView?.endUpdates()
 | |
|                 }
 | |
|             }
 | |
|             .store(in: &disposables)
 | |
|         
 | |
|         viewModel.emptyStateTextPublisher
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sink { [weak self] text in
 | |
|                 self?.emptyStateLabel.text = text
 | |
|             }
 | |
|             .store(in: &disposables)
 | |
|         
 | |
|         viewModel.footerView
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sink { [weak self] footerView in
 | |
|                 self?.tableView.tableFooterView = footerView
 | |
|             }
 | |
|             .store(in: &disposables)
 | |
|         
 | |
|         viewModel.footerButtonInfo
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sink { [weak self] buttonInfo in
 | |
|                 if let buttonInfo: SessionButton.Info = buttonInfo {
 | |
|                     self?.footerButton.setTitle(buttonInfo.title, for: .normal)
 | |
|                     self?.footerButton.setStyle(buttonInfo.style)
 | |
|                     self?.footerButton.isEnabled = buttonInfo.isEnabled
 | |
|                     self?.footerButton.set(.width, greaterThanOrEqualTo: buttonInfo.minWidth)
 | |
|                     self?.footerButton.accessibilityIdentifier = buttonInfo.accessibility?.identifier
 | |
|                     self?.footerButton.accessibilityLabel = buttonInfo.accessibility?.label
 | |
|                 }
 | |
|                 
 | |
|                 self?.onFooterTap = buttonInfo?.onTap
 | |
|                 self?.fadeView.isHidden = (buttonInfo == nil)
 | |
|                 self?.footerButton.isHidden = (buttonInfo == nil)
 | |
|                 
 | |
|                 // If we have a footerButton then we want to manually control the contentInset
 | |
|                 self?.tableView.contentInsetAdjustmentBehavior = (buttonInfo == nil ? .automatic : .never)
 | |
|                 self?.tableView.contentInset = UIEdgeInsets(
 | |
|                     top: 0,
 | |
|                     left: 0,
 | |
|                     bottom: (buttonInfo == nil ?
 | |
|                         0 :
 | |
|                         Values.footerGradientHeight(window: UIApplication.shared.keyWindow)
 | |
|                     ),
 | |
|                     right: 0
 | |
|                 )
 | |
|             }
 | |
|             .store(in: &disposables)
 | |
|     }
 | |
|     
 | |
|     @objc private func footerButtonTapped() {
 | |
|         onFooterTap?()
 | |
|     }
 | |
|     
 | |
|     // MARK: - UITableViewDataSource
 | |
|     
 | |
|     func numberOfSections(in tableView: UITableView) -> Int {
 | |
|         return self.viewModel.tableData.count
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
 | |
|         return self.viewModel.tableData[section].elements.count
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
 | |
|         let section: SectionModel = viewModel.tableData[indexPath.section]
 | |
|         let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
 | |
|         let cell: UITableViewCell = tableView.dequeue(type: viewModel.cellType.viewType.self, for: indexPath)
 | |
|         
 | |
|         switch (cell, info) {
 | |
|             case (let cell as SessionCell, _):
 | |
|                 cell.update(with: info)
 | |
|                 cell.update(
 | |
|                     isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)),
 | |
|                     becomeFirstResponder: false,
 | |
|                     animated: false
 | |
|                 )
 | |
|                 
 | |
|                 switch viewModel {
 | |
|                     case let editableStateHolder as ErasedEditableStateHolder:
 | |
|                         cell.textPublisher
 | |
|                             .sink(receiveValue: { [weak editableStateHolder] text in
 | |
|                                 editableStateHolder?.textChanged(text, for: info.id)
 | |
|                             })
 | |
|                             .store(in: &cell.disposables)
 | |
|                     default: break
 | |
|                 }
 | |
|                 
 | |
|             case (let cell as FullConversationCell, let threadInfo as SessionCell.Info<SessionThreadViewModel>):
 | |
|                 cell.accessibilityIdentifier = info.accessibility?.identifier
 | |
|                 cell.isAccessibilityElement = (info.accessibility != nil)
 | |
|                 cell.update(with: threadInfo.id)
 | |
|                 
 | |
|             default:
 | |
|                 SNLog("[SessionTableViewController] Got invalid combination of cellType: \(viewModel.cellType) and tableData: \(SessionCell.Info<TableItem>.self)")
 | |
|         }
 | |
|         
 | |
|         return cell
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
 | |
|         let section: SectionModel = viewModel.tableData[section]
 | |
|         let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
 | |
|         result.update(
 | |
|             title: section.model.title,
 | |
|             style: section.model.style
 | |
|         )
 | |
|         
 | |
|         return result
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
 | |
|         let section: SectionModel = viewModel.tableData[section]
 | |
|         
 | |
|         if let footerString = section.model.footer {
 | |
|             let result: SessionFooterView = tableView.dequeueHeaderFooterView(type: SessionFooterView.self)
 | |
|             result.update(title: footerString)
 | |
|             
 | |
|             return result
 | |
|         }
 | |
|         
 | |
|         return UIView()
 | |
|     }
 | |
|     
 | |
|     // MARK: - UITableViewDelegate
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
 | |
|         return viewModel.tableData[section].model.style.height
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
 | |
|         let section: SectionModel = viewModel.tableData[section]
 | |
|         
 | |
|         return (section.model.footer == nil ? 0 : UITableView.automaticDimension)
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
 | |
|         return UITableView.automaticDimension
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
 | |
|         return UITableView.automaticDimension
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
 | |
|         guard self.hasLoadedInitialTableData && self.viewHasAppeared && !self.isLoadingMore else { return }
 | |
|         
 | |
|         let section: SectionModel = self.viewModel.tableData[section]
 | |
|         
 | |
|         switch section.model.style {
 | |
|             case .loadMore:
 | |
|                 self.isLoadingMore = true
 | |
|                 
 | |
|                 DispatchQueue.global(qos: .userInitiated).async { [weak self] in
 | |
|                     (self?.viewModel as? (any PagedObservationSource))?.loadPageAfter()
 | |
|                 }
 | |
|                 
 | |
|             default: break
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
 | |
|         return viewModel.canEditRow(at: indexPath)
 | |
|     }
 | |
|     
 | |
|     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, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
 | |
|         return viewModel.leadingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
 | |
|     }
 | |
|     
 | |
|     func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
 | |
|         return viewModel.trailingSwipeActionsConfiguration(forRowAt: indexPath, in: tableView, of: self)
 | |
|     }
 | |
| 
 | |
|     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
 | |
|         tableView.deselectRow(at: indexPath, animated: true)
 | |
|         
 | |
|         let section: SectionModel = self.viewModel.tableData[indexPath.section]
 | |
|         let info: SessionCell.Info<TableItem> = section.elements[indexPath.row]
 | |
|         
 | |
|         // Do nothing if the item is disabled
 | |
|         guard info.isEnabled else { return }
 | |
|         
 | |
|         // Get the view that was tapped (for presenting on iPad)
 | |
|         let tappedView: UIView? = {
 | |
|             guard let cell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell else {
 | |
|                 return nil
 | |
|             }
 | |
|             
 | |
|             switch (info.leftAccessory, info.rightAccessory) {
 | |
|                 case (_, .highlightingBackgroundLabel(_, _)):
 | |
|                     return (!cell.rightAccessoryView.isHidden ? cell.rightAccessoryView : cell)
 | |
|                     
 | |
|                 case (.highlightingBackgroundLabel(_, _), _):
 | |
|                     return (!cell.leftAccessoryView.isHidden ? cell.leftAccessoryView : cell)
 | |
|                 
 | |
|                 default:
 | |
|                     return cell
 | |
|             }
 | |
|         }()
 | |
|         let maybeOldSelection: (Int, SessionCell.Info<TableItem>)? = section.elements
 | |
|             .enumerated()
 | |
|             .first(where: { index, info in
 | |
|                 switch (info.leftAccessory, info.rightAccessory) {
 | |
|                     case (_, .radio(_, let isSelected, _, _)): return isSelected()
 | |
|                     case (.radio(_, let isSelected, _, _), _): return isSelected()
 | |
|                     default: return false
 | |
|                 }
 | |
|             })
 | |
|         
 | |
|         let performAction: () -> Void = { [weak self, weak tappedView] in
 | |
|             info.onTap?()
 | |
|             info.onTapView?(tappedView)
 | |
|             self?.manuallyReload(indexPath: indexPath, section: section, info: info)
 | |
|             
 | |
|             // Update the old selection as well
 | |
|             if let oldSelection: (index: Int, info: SessionCell.Info<TableItem>) = maybeOldSelection {
 | |
|                 self?.manuallyReload(
 | |
|                     indexPath: IndexPath(
 | |
|                         row: oldSelection.index,
 | |
|                         section: indexPath.section
 | |
|                     ),
 | |
|                     section: section,
 | |
|                     info: oldSelection.info
 | |
|                 )
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         guard
 | |
|             let confirmationInfo: ConfirmationModal.Info = info.confirmationInfo,
 | |
|             confirmationInfo.showCondition.shouldShow(for: info.currentBoolValue)
 | |
|         else {
 | |
|             performAction()
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         // Show a confirmation modal before continuing
 | |
|         let confirmationModal: ConfirmationModal = ConfirmationModal(
 | |
|             targetView: tappedView,
 | |
|             info: confirmationInfo
 | |
|                 .with(onConfirm: { _ in performAction() })
 | |
|         )
 | |
|         present(confirmationModal, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     private func manuallyReload(
 | |
|         indexPath: IndexPath,
 | |
|         section: SectionModel,
 | |
|         info: SessionCell.Info<TableItem>
 | |
|     ) {
 | |
|         // Try update the existing cell to have a nice animation instead of reloading the cell
 | |
|         if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell {
 | |
|             existingCell.update(with: info, isManualReload: true)
 | |
|         }
 | |
|         else {
 | |
|             tableView.reloadRows(at: [indexPath], with: .none)
 | |
|         }
 | |
|     }
 | |
| }
 |