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.
		
		
		
		
		
			
		
			
				
	
	
		
			417 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			417 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
| // 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<Section, Profile>] = [
 | |
|         ArraySection(model: .contacts, elements: contactProfiles)
 | |
|     ]
 | |
|     private var selectedContacts: Set<String> = []
 | |
|     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,
 | |
|                     body: .text(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
 | |
|             MessageSender
 | |
|                 .createClosedGroup(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(),
 | |
|                                         body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()),
 | |
|                                         cancelTitle: "BUTTON_OK".localized(),
 | |
|                                         cancelStyle: .alert_text
 | |
|                                     )
 | |
|                                 )
 | |
|                                 self?.present(modal, animated: true)
 | |
|                         }
 | |
|                     },
 | |
|                     receiveValue: { thread in
 | |
|                         SessionApp.presentConversationCreatingIfNeeded(
 | |
|                             for: thread.id,
 | |
|                             variant: thread.variant,
 | |
|                             dismissing: self?.presentingViewController,
 | |
|                             animated: false
 | |
|                         )
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| extension NewClosedGroupVC: UISearchBarDelegate {
 | |
|     func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
 | |
|         self.searchText = searchText
 | |
|         
 | |
|         let changeset: StagedChangeset<[ArraySection<Section, Profile>]> = 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()
 | |
|     }
 | |
| }
 |