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.
440 lines
18 KiB
Swift
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 = 20
|
|
|
|
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.backgroundColor = .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.tintColor = Colors.text
|
|
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)
|
|
}
|
|
}
|