session-ios/Session/Shared/SessionTableViewController.swift

494 lines
19 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<NavItemId: Equatable, Section: SessionTableSection, SettingItem: Hashable & Differentiable>: BaseVC, UITableViewDataSource, UITableViewDelegate, SessionViewModelAccessible {
typealias SectionModel = SessionTableViewModel<NavItemId, Section, SettingItem>.SectionModel
private let viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>
private var hasLoadedInitialSettingsData: Bool = false
private var dataStreamJustFailed: Bool = false
private var dataChangeCancellable: AnyCancellable?
private var disposables: Set<AnyCancellable> = Set()
public var viewModelType: AnyObject.Type { return type(of: viewModel) }
// MARK: - Components
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: SessionAvatarCell.self)
result.register(view: SessionCell.self)
result.registerHeaderFooterView(view: SessionHeaderView.self)
result.dataSource = self
result.delegate = self
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
// MARK: - Initialization
init(viewModel: SessionTableViewModel<NavItemId, Section, SettingItem>) {
self.viewModel = viewModel
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()
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: viewModel.title,
hasCustomBackButton: false
)
view.themeBackgroundColor = .backgroundPrimary
view.addSubview(tableView)
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 viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
startObservingChanges()
}
@objc func applicationDidResignActive(_ notification: Notification) {
stopObservingChanges()
}
private func setupLayout() {
tableView.pin(to: view)
}
// MARK: - Updating
private func startObservingChanges() {
// Start observing for data changes
dataChangeCancellable = viewModel.observableSettingsData
.receiveOnMain(
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
immediately: !hasLoadedInitialSettingsData
)
.sink(
receiveCompletion: { [weak self] result in
switch result {
case .failure(let error):
let title: String = (self?.viewModel.title ?? "unknown")
// 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()
case .finished: break
}
},
receiveValue: { [weak self] settingsData in
self?.dataStreamJustFailed = false
self?.handleSettingsUpdates(settingsData)
}
)
}
private func stopObservingChanges() {
// Stop observing database changes
dataChangeCancellable?.cancel()
}
private func handleSettingsUpdates(_ updatedData: [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 hasLoadedInitialSettingsData else {
hasLoadedInitialSettingsData = true
UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) }
return
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.settingsData, target: updatedData),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .none,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateSettings(updatedData)
}
}
// MARK: - Binding
private func setupBinding() {
viewModel.isEditing
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing in
self?.setEditing(isEditing, animated: true)
self?.tableView.visibleCells.forEach { cell in
switch cell {
case let cell as SessionCell:
cell.update(isEditing: isEditing, animated: true)
case let avatarCell as SessionAvatarCell:
avatarCell.update(isEditing: isEditing, animated: true)
default: break
}
}
}
.store(in: &disposables)
viewModel.leftNavItems
.receiveOnMain(immediately: true)
.sink { [weak self] maybeItems in
self?.navigationItem.setLeftBarButtonItems(
maybeItems.map { items in
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.handleEvents(receiveOutput: { _ in item.action?() })
.sink(into: self?.viewModel.navItemTapped)
.store(in: &buttonItem.disposables)
return buttonItem
}
},
animated: true
)
}
.store(in: &disposables)
viewModel.rightNavItems
.receiveOnMain(immediately: true)
.sink { [weak self] maybeItems in
self?.navigationItem.setRightBarButtonItems(
maybeItems.map { items in
items.map { item -> DisposableBarButtonItem in
let buttonItem: DisposableBarButtonItem = item.createBarButtonItem()
buttonItem.themeTintColor = .textPrimary
buttonItem.tapPublisher
.map { _ in item.id }
.handleEvents(receiveOutput: { _ in item.action?() })
.sink(into: self?.viewModel.navItemTapped)
.store(in: &buttonItem.disposables)
return buttonItem
}
},
animated: true
)
}
.store(in: &disposables)
viewModel.footerView
.receiveOnMain(immediately: true)
.sink { [weak self] footerView in
self?.tableView.tableFooterView = footerView
}
.store(in: &disposables)
viewModel.showToast
.receive(on: DispatchQueue.main)
.sink { [weak self] text, color in
guard let view: UIView = self?.view else { return }
let toastController: ToastController = ToastController(text: text, background: color)
toastController.presentToastView(fromBottomOfView: view, inset: Values.largeSpacing)
}
.store(in: &disposables)
viewModel.transitionToScreen
.receive(on: DispatchQueue.main)
.sink { [weak self] viewController, transitionType in
switch transitionType {
case .push:
self?.navigationController?.pushViewController(viewController, animated: true)
case .present:
if UIDevice.current.isIPad {
viewController.popoverPresentationController?.permittedArrowDirections = []
viewController.popoverPresentationController?.sourceView = self?.view
viewController.popoverPresentationController?.sourceRect = (self?.view.bounds ?? UIScreen.main.bounds)
}
self?.present(viewController, animated: true)
}
}
.store(in: &disposables)
viewModel.dismissScreen
.receive(on: DispatchQueue.main)
.sink { [weak self] dismissType in
switch dismissType {
case .auto:
guard
let viewController: UIViewController = self,
(self?.navigationController?.viewControllers
.firstIndex(of: viewController))
.defaulting(to: 0) > 0
else {
self?.dismiss(animated: true)
return
}
self?.navigationController?.popViewController(animated: true)
case .dismiss: self?.dismiss(animated: true)
case .pop: self?.navigationController?.popViewController(animated: true)
}
}
.store(in: &disposables)
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return self.viewModel.settingsData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.viewModel.settingsData[section].elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: SectionModel = viewModel.settingsData[indexPath.section]
let info: SessionCell.Info<SettingItem> = section.elements[indexPath.row]
switch info.leftAccessory {
case .threadInfo(let threadViewModel, let style, let avatarTapped, let titleTapped, let titleChanged):
let cell: SessionAvatarCell = tableView.dequeue(type: SessionAvatarCell.self, for: indexPath)
cell.update(
threadViewModel: threadViewModel,
style: style,
viewController: self
)
cell.update(isEditing: self.isEditing, animated: false)
cell.profilePictureTapPublisher
.filter { _ in threadViewModel.threadVariant == .contact }
.sink(receiveValue: { _ in avatarTapped?() })
.store(in: &cell.disposables)
cell.displayNameTapPublisher
.filter { _ in threadViewModel.threadVariant == .contact }
.sink(receiveValue: { _ in titleTapped?() })
.store(in: &cell.disposables)
cell.textPublisher
.sink(receiveValue: { text in titleChanged?(text) })
.store(in: &cell.disposables)
return cell
default:
let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath)
cell.update(
with: info,
style: .rounded,
position: Position.with(indexPath.row, count: section.elements.count)
)
cell.update(isEditing: self.isEditing, animated: false)
return cell
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: SectionModel = viewModel.settingsData[section]
switch section.model.style {
case .none:
return UIView()
case .padding, .title:
let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self)
result.update(
title: section.model.title,
hasSeparator: (section.elements.first?.shouldHaveBackground != false)
)
return result
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: SectionModel = viewModel.settingsData[section]
switch section.model.style {
case .none: return 0
case .padding, .title: return 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, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: SectionModel = self.viewModel.settingsData[indexPath.section]
let info: SessionCell.Info<SettingItem> = 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<SettingItem>)? = 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?(tappedView)
self?.manuallyReload(indexPath: indexPath, section: section, info: info)
// Update the old selection as well
if let oldSelection: (index: Int, info: SessionCell.Info<SettingItem>) = maybeOldSelection {
self?.manuallyReload(
indexPath: IndexPath(
row: oldSelection.index,
section: indexPath.section
),
section: section,
info: oldSelection.info
)
}
}
guard
let confirmationInfo: ConfirmationModal.Info = info.confirmationInfo,
confirmationInfo.stateToShow.shouldShow(for: info.currentBoolValue)
else {
performAction()
return
}
// Show a confirmation modal before continuing
let confirmationModal: ConfirmationModal = ConfirmationModal(
targetView: tappedView,
info: confirmationInfo
.with(onConfirm: { [weak self] _ in
performAction()
self?.dismiss(animated: true)
})
)
present(confirmationModal, animated: true, completion: nil)
}
private func manuallyReload(
indexPath: IndexPath,
section: SectionModel,
info: SessionCell.Info<SettingItem>
) {
// 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,
style: .rounded,
position: Position.with(indexPath.row, count: section.elements.count)
)
}
else {
tableView.reloadRows(at: [indexPath], with: .none)
}
}
}