session-ios/Session/Closed Groups/NewClosedGroupVC.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()
}
}