// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit private protocol TableViewTouchDelegate { func tableViewWasTouched(_ tableView: TableView, withView hitView: UIView?) } private final class TableView: UITableView { var touchDelegate: TableViewTouchDelegate? override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let resultingView: UIView? = super.hitTest(point, with: event) touchDelegate?.tableViewWasTouched(self, withView: resultingView) return resultingView } } final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { private enum Section: Int, Differentiable, Equatable, Hashable { case contacts } private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true) private lazy var data: [ArraySection] = [ ArraySection(model: .contacts, elements: contactProfiles) ] private var selectedContacts: Set = [] private var searchText: String = "" // MARK: - Components private static let textFieldHeight: CGFloat = 50 private static let searchBarHeight: CGFloat = (36 + (Values.mediumSpacing * 2)) private lazy var nameTextField: TextField = { let result = TextField( placeholder: "vc_create_closed_group_text_field_hint".localized(), usesDefaultHeight: false, customHeight: NewClosedGroupVC.textFieldHeight ) result.set(.height, to: NewClosedGroupVC.textFieldHeight) result.themeBorderColor = .borderSeparator result.layer.cornerRadius = 13 result.delegate = self result.accessibilityIdentifier = "Group name input" result.isAccessibilityElement = true return result }() private lazy var searchBar: ContactsSearchBar = { let result = ContactsSearchBar() result.themeTintColor = .textPrimary result.themeBackgroundColor = .clear result.delegate = self result.set(.height, to: NewClosedGroupVC.searchBarHeight) return result }() private lazy var headerView: UIView = { let result: UIView = UIView( frame: CGRect( x: 0, y: 0, width: UIScreen.main.bounds.width, height: ( Values.mediumSpacing + NewClosedGroupVC.textFieldHeight + NewClosedGroupVC.searchBarHeight ) ) ) result.addSubview(nameTextField) result.addSubview(searchBar) nameTextField.pin(.top, to: .top, of: result, withInset: Values.mediumSpacing) nameTextField.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing) nameTextField.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing) // Note: The top & bottom padding is built into the search bar searchBar.pin(.top, to: .bottom, of: nameTextField) searchBar.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing) searchBar.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing) searchBar.pin(.bottom, to: .bottom, of: result) return result }() private lazy var tableView: TableView = { let result: TableView = TableView() result.separatorStyle = .none result.themeBackgroundColor = .clear result.showsVerticalScrollIndicator = false result.tableHeaderView = headerView result.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow), trailing: 0 ) result.register(view: SessionCell.self) result.touchDelegate = self result.dataSource = self result.delegate = self if #available(iOS 15.0, *) { result.sectionHeaderTopPadding = 0 } return result }() private lazy var fadeView: GradientView = { let result: GradientView = GradientView() result.themeBackgroundGradient = [ .value(.newConversation_background, alpha: 0), // Want this to take up 20% (~25pt) .newConversation_background, .newConversation_background, .newConversation_background, .newConversation_background ] result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow)) return result }() private lazy var createGroupButton: SessionButton = { let result = SessionButton(style: .bordered, size: .large) result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("CREATE_GROUP_BUTTON_TITLE".localized(), for: .normal) result.addTarget(self, action: #selector(createClosedGroup), for: .touchUpInside) result.accessibilityIdentifier = "Create group" result.isAccessibilityElement = true result.set(.width, to: 160) return result }() // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() view.themeBackgroundColor = .newConversation_background let customTitleFontSize = Values.largeFontSize setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize) let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.themeTintColor = .textPrimary navigationItem.rightBarButtonItem = closeButton navigationItem.leftBarButtonItem?.accessibilityIdentifier = "Cancel" navigationItem.leftBarButtonItem?.isAccessibilityElement = true // Set up content setUpViewHierarchy() } private func setUpViewHierarchy() { guard !contactProfiles.isEmpty else { let explanationLabel: UILabel = UILabel() explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = "vc_create_closed_group_empty_state_message".localized() explanationLabel.themeTextColor = .textSecondary explanationLabel.textAlignment = .center explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.numberOfLines = 0 view.addSubview(explanationLabel) explanationLabel.pin(.top, to: .top, of: view, withInset: Values.largeSpacing) explanationLabel.center(.horizontal, in: view) return } view.addSubview(tableView) tableView.pin(.top, to: .top, of: view) tableView.pin(.leading, to: .leading, of: view) tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) view.addSubview(fadeView) fadeView.pin(.leading, to: .leading, of: view) fadeView.pin(.trailing, to: .trailing, of: view) fadeView.pin(.bottom, to: .bottom, of: view) view.addSubview(createGroupButton) createGroupButton.center(.horizontal, in: view) createGroupButton.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing) } // MARK: - Table View Data Source func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return data[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath) let profile: Profile = data[indexPath.section].elements[indexPath.row] cell.update( with: SessionCell.Info( id: profile, position: Position.with(indexPath.row, count: data[indexPath.section].elements.count), leftAccessory: .profile(id: profile.id, profile: profile), title: profile.displayName(), rightAccessory: .radio(isSelected: { [weak self] in self?.selectedContacts.contains(profile.id) == true }), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), accessibility: Accessibility( identifier: "Contact" ) ) ) return cell } // 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, didSelectRowAt indexPath: IndexPath) { let profileId: String = data[indexPath.section].elements[indexPath.row].id if !selectedContacts.contains(profileId) { selectedContacts.insert(profileId) } else { selectedContacts.remove(profileId) } tableView.deselectRow(at: indexPath, animated: true) tableView.reloadRows(at: [indexPath], with: .none) } func scrollViewDidScroll(_ scrollView: UIScrollView) { let nameTextFieldCenterY = nameTextField.convert(nameTextField.bounds.center, to: scrollView).y let shouldShowGroupNameInTitle: Bool = (scrollView.contentOffset.y > nameTextFieldCenterY) let groupNameLabelVisible: Bool = (crossfadeLabel.alpha >= 1) switch (shouldShowGroupNameInTitle, groupNameLabelVisible) { case (true, false): UIView.animate(withDuration: 0.2) { self.navBarTitleLabel.alpha = 0 self.crossfadeLabel.alpha = 1 } case (false, true): UIView.animate(withDuration: 0.2) { self.navBarTitleLabel.alpha = 1 self.crossfadeLabel.alpha = 0 } default: break } } // MARK: - Interaction func textFieldDidEndEditing(_ textField: UITextField) { crossfadeLabel.text = (textField.text?.isEmpty == true ? "vc_create_closed_group_title".localized() : textField.text ) } fileprivate func tableViewWasTouched(_ tableView: TableView, withView hitView: UIView?) { if nameTextField.isFirstResponder { nameTextField.resignFirstResponder() } else if searchBar.isFirstResponder { var hitSuperview: UIView? = hitView?.superview while hitSuperview != nil && hitSuperview != searchBar { hitSuperview = hitSuperview?.superview } // If the user hit the cancel button then do nothing (we want to let the cancel // button remove the focus or it will instantly refocus) if hitSuperview == searchBar { return } searchBar.resignFirstResponder() } } @objc private func close() { dismiss(animated: true, completion: nil) } @objc private func createClosedGroup() { func showError(title: String, message: String = "") { let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: title, explanation: message, cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) ) present(modal, animated: true) } guard let name: String = nameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), name.count > 0 else { return showError(title: "vc_create_closed_group_group_name_missing_error".localized()) } guard name.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else { return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } guard selectedContacts.count >= 1 else { return showError(title: "GROUP_ERROR_NO_MEMBER_SELECTION".localized()) } guard selectedContacts.count < 100 else { // Minus one because we're going to include self later return showError(title: "vc_create_closed_group_too_many_group_members_error".localized()) } let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in Storage.shared .writePublisherFlatMap { db in try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sinkUntilComplete( receiveCompletion: { result in switch result { case .finished: break case .failure: self?.dismiss(animated: true, completion: nil) // Dismiss the loader let modal: ConfirmationModal = ConfirmationModal( targetView: self?.view, info: ConfirmationModal.Info( title: "GROUP_CREATION_ERROR_TITLE".localized(), explanation: "GROUP_CREATION_ERROR_MESSAGE".localized(), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) ) self?.present(modal, animated: true) } }, receiveValue: { thread in self?.presentingViewController?.dismiss(animated: true, completion: nil) SessionApp.presentConversation(for: thread.id, action: .compose, animated: false) } ) } } } extension NewClosedGroupVC: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { self.searchText = searchText let changeset: StagedChangeset<[ArraySection]> = StagedChangeset( source: data, target: [ ArraySection( model: .contacts, elements: (searchText.isEmpty ? contactProfiles : contactProfiles .filter { $0.displayName().range(of: searchText, options: [.caseInsensitive]) != nil } ) ) ] ) self.tableView.reload( using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, deleteRowsAnimation: .none, insertRowsAnimation: .none, reloadRowsAnimation: .none, interrupt: { $0.changeCount > 100 } ) { [weak self] updatedData in self?.data = updatedData } } func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { searchBar.setShowsCancelButton(true, animated: true) return true } func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { searchBar.setShowsCancelButton(false, animated: true) return true } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.resignFirstResponder() } }