session-ios/Session/Settings/BlockedContactsViewControll...

440 lines
18 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private static let loadingHeaderHeight: CGFloat = 40
private let viewModel: BlockedContactsViewModel = BlockedContactsViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialContactData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// 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 tableView: UITableView = {
let result: UITableView = UITableView()
result.translatesAutoresizingMaskIntoConstraints = false
result.themeBackgroundColor = .clear
result.separatorStyle = .none
result.register(view: BlockedContactCell.self)
result.dataSource = self
result.delegate = self
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
result.showsVerticalScrollIndicator = false
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 = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized()
result.themeTextColor = .textSecondary
result.textAlignment = .center
result.numberOfLines = 0
result.isHidden = true
return result
}()
private lazy var unblockButton: OutlineButton = {
let result: OutlineButton = OutlineButton(style: .destructive, size: .large)
result.translatesAutoresizingMaskIntoConstraints = false
result.setTitle("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(), for: .normal)
result.addTarget(self, action: #selector(unblockTapped), for: .touchUpInside)
return result
}()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_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(tableView)
view.addSubview(emptyStateLabel)
view.addSubview(unblockButton)
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)
// Stop observing database changes
dataChangeObservable?.cancel()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
startObservingChanges(didReturnFromBackground: true)
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
}
// MARK: - Layout
private func setupLayout() {
NSLayoutConstraint.activate([
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),
unblockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
unblockButton.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -Values.largeSpacing
),
unblockButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth),
unblockButton.heightAnchor.constraint(equalToConstant: NewConversationButtonSet.collapsedButtonSize)
])
}
// MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) {
self.viewModel.onContactChange = { [weak self] updatedContactData in
self?.handleContactUpdates(updatedContactData)
}
// 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 handleContactUpdates(_ updatedData: [BlockedContactsViewModel.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 hasLoadedInitialContactData else {
hasLoadedInitialContactData = true
UIView.performWithoutAnimation { handleContactUpdates(updatedData, initialLoad: true) }
return
}
// Show the empty state if there is no data
let hasContactsData: Bool = (updatedData
.first(where: { $0.model == .contacts })?
.elements
.isEmpty == false)
unblockButton.isEnabled = !viewModel.selectedContactIds.isEmpty
unblockButton.isHidden = !hasContactsData
emptyStateLabel.isHidden = hasContactsData
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: StagedChangeset(source: viewModel.contactData, target: updatedData),
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.updateContactData(updatedData)
}
CATransaction.commit()
}
private func autoLoadNextPageIfNeeded() {
guard !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: [(BlockedContactsViewModel.Section, CGRect)] = (self?.viewModel.contactData
.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: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
}
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.contactData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[indexPath.section]
switch section.model {
case .contacts:
let cellViewModel: BlockedContactsViewModel.DataModel = section.elements[indexPath.row]
let cell: BlockedContactCell = tableView.dequeue(type: BlockedContactCell.self, for: indexPath)
cell.update(
with: cellViewModel,
isSelected: viewModel.selectedContactIds.contains(cellViewModel.id)
)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[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, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section]
switch section.model {
case .loadMore: return BlockedContactsViewController.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialContactData && self.viewHasAppeared && !self.isLoadingMore else { return }
let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).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: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[indexPath.section]
switch section.model {
case .contacts:
let cellViewModel: BlockedContactsViewModel.DataModel = section.elements[indexPath.row]
self.viewModel.toggleSelection(contactId: cellViewModel.id)
self.tableView.reloadRows(at: [indexPath], with: .none)
self.unblockButton.isEnabled = !self.viewModel.selectedContactIds.isEmpty
default: break
}
}
// MARK: - Interaction
@objc private func unblockTapped() {
guard !viewModel.selectedContactIds.isEmpty else { return }
let contactIds: Set<String> = viewModel.selectedContactIds
let contactNames: [String] = contactIds
.map { contactId in
guard
let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData
.first(where: { section in section.model == .contacts }),
let viewModel: BlockedContactsViewModel.DataModel = section.elements
.first(where: { model in model.id == contactId })
else { return contactId }
return viewModel.profile.displayName()
}
let confirmationTitle: String = {
guard contactNames.count > 1 else {
// Show a single users name
return String(
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(),
(
contactNames.first ??
"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized()
)
)
}
guard contactNames.count > 3 else {
// Show up to three users names
let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1)))
let lastName: String = contactNames[contactNames.count - 1]
return [
String(
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
initialNames.joined(separator: ", ")
),
String(
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(),
lastName
),
].joined(separator: " ")
}
// If we have exactly 4 users, show the first two names followed by 'and X others', for
// more than 4 users, show the first 3 names followed by 'and X others'
let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3)
let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow))
return [
String(
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(),
initialNames.joined(separator: ", ")
),
String(
format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(),
(contactNames.count - numNamesToShow)
),
].joined(separator: " ")
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: confirmationTitle,
confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(),
confirmStyle: .danger,
cancelStyle: .textPrimary
) { [weak self] _ in
// Unblock the contacts
Storage.shared.write { db in
_ = try Contact
.filter(ids: contactIds)
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
// Force a config sync
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
}
)
self.present(confirmationModal, animated: true, completion: nil)
}
}