// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import ContactsUI import SignalServiceKit import SignalUI protocol NameCollisionResolutionDelegate: AnyObject { // For message requests, we should piggyback on the same action sheet that's presented // by the message request actions func createBlockThreadActionSheet(sheetCompletion: ((Bool) -> Void)?) -> ActionSheetController // Invoked when the controller requests dismissal func nameCollisionControllerDidComplete(_ controller: NameCollisionResolutionViewController, dismissConversationView: Bool) } class NameCollisionResolutionViewController: OWSTableViewController2 { private let collisionFinder: NameCollisionFinder private var thread: TSThread { collisionFinder.thread } private var groupViewHelper: GroupViewHelper? private weak var collisionDelegate: NameCollisionResolutionDelegate? // The actual table UI doesn't section off one collision from the next // As a convenience, here's a flattened window of cell models private var flattenedCellModels: [NameCollisionCellModel] { cellModels.lazy.flatMap { $0 } } private var cellModels: [[NameCollisionCellModel]] = [] { didSet { if cellModels.count == 0 || cellModels.allSatisfy({ $0.count <= 1 }) { databaseStorage.asyncWrite { writeTx in self.collisionFinder.markCollisionsAsResolved(transaction: writeTx) } collisionDelegate?.nameCollisionControllerDidComplete(self, dismissConversationView: false) } else { updateTableContents() } } } init(collisionFinder: NameCollisionFinder, collisionDelegate: NameCollisionResolutionDelegate) { self.collisionFinder = collisionFinder self.collisionDelegate = collisionDelegate super.init() contactsViewHelper.addObserver(self) } override func viewDidLoad() { super.viewDidLoad() updateModel() tableView.separatorStyle = .none } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.leftBarButtonItem = UIBarButtonItem( barButtonSystemItem: .done, target: self, action: #selector(donePressed)) navigationItem.rightBarButtonItem = nil } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate { _ in // Force tableview to recalculate self-sized cell height self.tableView.beginUpdates() self.tableView.endUpdates() } } func updateModel() { cellModels = databaseStorage.read(block: { readTx in if self.groupViewHelper == nil, self.thread.isGroupThread { let threadViewModel = ThreadViewModel(thread: self.thread, forChatList: false, transaction: readTx) self.groupViewHelper = GroupViewHelper(threadViewModel: threadViewModel) self.groupViewHelper?.delegate = self } let standardSort = self.collisionFinder.findCollisions(transaction: readTx).standardSort(readTx: readTx) guard standardSort.count > 0, standardSort.allSatisfy({ $0.elements.count >= 2 }) else { return [] } let sortedCollisions: [NameCollision] if let contactAddress = (self.thread as? TSContactThread)?.contactAddress, standardSort.count == 1, let requesterIndex = standardSort[0].elements.firstIndex(where: { $0.address == contactAddress }) { // If we're in a contact thread, the one exception to the standard sorting of collisions // is that the message requester should appear first var collidingElements = standardSort[0].elements let requesterElement = collidingElements.remove(at: requesterIndex) collidingElements.insert(requesterElement, at: 0) sortedCollisions = [NameCollision(collidingElements)] } else { sortedCollisions = standardSort } return sortedCollisions.map { $0.collisionCellModels( thread: self.thread, identityManager: DependenciesBridge.shared.identityManager, profileManager: profileManager, blockingManager: blockingManager, contactsManager: contactsManager, viewControllerForPresentation: self, tx: readTx ) } }) } func updateTableContents() { let titleString: String if thread.isGroupThread { titleString = OWSLocalizedString( "GROUP_MEMBERSHIP_NAME_COLLISION_TITLE", comment: "A title string for a view that allows a user to review name collisions in group membership") } else { titleString = OWSLocalizedString( "MESSAGE_REQUEST_NAME_COLLISON_TITLE", comment: "A title string for a view that allows a user to review name collisions for an incoming message request") } let shouldShowSectionHeaders = cellModels.count > 1 contents = OWSTableContents( title: titleString, sections: [ createHeaderSection() ] + cellModels .map { model in createSections( for: model, shouldShowHeader: shouldShowSectionHeaders ) } .flatMap { $0 } ) } func createHeaderSection() -> OWSTableSection { OWSTableSection(header: { let view = UIView() let label = UILabel() label.textColor = Theme.secondaryTextAndIconColor label.font = UIFont.dynamicTypeFootnote label.adjustsFontForContentSizeCategory = true label.numberOfLines = 0 if thread.isGroupThread, cellModels.count >= 2 { let format = OWSLocalizedString( "GROUP_MEMBERSHIP_NAME_MULTIPLE_COLLISION_HEADER_%d", tableName: "PluralAware", comment: "A header string informing the user about a name collision in group membership. Embeds {{ number of sets of colliding members }}") label.text = String.localizedStringWithFormat(format, cellModels.count) } else if thread.isGroupThread { let format = OWSLocalizedString( "GROUP_MEMBERSHIP_NAME_SINGLE_COLLISION_HEADER_%d", tableName: "PluralAware", comment: "A header string informing the user about a name collision in group membership. Embeds {{ total number of colliding members }}") label.text = String.localizedStringWithFormat(format, flattenedCellModels.count) } else { label.text = OWSLocalizedString( "MESSAGE_REQUEST_NAME_COLLISON_HEADER", comment: "A header string informing the user about name collisions in a message request") } view.addSubview(label) label.autoPinEdgesToSuperviewEdges(with: .init(hMargin: 32, vMargin: 12)) return view }) } private func createSections( for models: [NameCollisionCellModel], shouldShowHeader: Bool ) -> [OWSTableSection] { owsAssertDebug(models.count > 1) let title: String? if shouldShowHeader { let format = OWSLocalizedString( "GROUP_MEMBERSHIP_NAME_COLLISION_MEMBER_COUNT_%d", tableName: "PluralAware", comment: "A header string above a section of group members whose names conflict." ) title = String.localizedStringWithFormat(format, models.count) } else { title = nil } return models.enumerated().map { index, model in OWSTableSection( title: index == 0 ? title : nil, items: [createCell(for: model)] ) } } private func createCell(for model: NameCollisionCellModel) -> OWSTableItem { let action: NameCollisionCell.Action? = { switch (thread: thread, address: model.address, isBlocked: model.isBlocked) { case (thread: is TSContactThread, address: flattenedCellModels.first?.address, isBlocked: false): return .block { [weak self] in self?.blockThread() } case (thread: is TSContactThread, address: flattenedCellModels.first?.address, isBlocked: true): return .unblock { [weak self] in self?.unblock(address: model.address) } case (_, let address, _) where shouldShowContactUpdateAction(for: address): return .updateContact { [weak self] in self?.presentUpdateContactViewController(for: address) } case (thread: is TSGroupThread, let address, _) where !address.isLocalAddress && groupViewHelper?.canRemoveFromGroup(address: model.address) == true: return .removeFromGroup { [weak self] in self?.removeFromGroup(model.address) } case (thread: is TSGroupThread, let address, isBlocked: false) where !address.isLocalAddress: return .block {[weak self] in self?.blockAddress(model.address) } default: return nil } }() return OWSTableItem( customCellBlock: { NameCollisionCell.createWithModel(model, action: action) }, actionBlock: { [weak self] in guard let self = self else { return } ProfileSheetSheetCoordinator( address: model.address, groupViewHelper: self.groupViewHelper, spoilerState: SpoilerRenderState() // no need to share ).presentAppropriateSheet(from: self) } ) } // MARK: - Resolution Actions private func blockThread() { guard let collisionDelegate = collisionDelegate else { return } presentActionSheet(collisionDelegate.createBlockThreadActionSheet { [weak self] shouldDismiss in if shouldDismiss { guard let self = self else { return } collisionDelegate.nameCollisionControllerDidComplete(self, dismissConversationView: true) } }) } private func unblock(address: SignalServiceAddress) { BlockListUIUtils.showUnblockThreadActionSheet(thread, from: self) { [weak self] didUnblock in guard let self, didUnblock else { return } self.updateModel() } } private func blockAddress(_ address: SignalServiceAddress) { BlockListUIUtils.showBlockAddressActionSheet(address, from: self) { [weak self] (didBlock) in if didBlock { self?.updateModel() } } } private func removeFromGroup(_ address: SignalServiceAddress) { groupViewHelper?.presentRemoveFromGroupActionSheet(address: address) // groupViewHelper will call out to its delegate (us) when the membership } private func presentUpdateContactViewController(for address: SignalServiceAddress) { contactsViewHelper.presentSystemContactsFlow( CreateOrEditContactFlow(address: address), from: self ) // We observe contact updates and will automatically update our model in response } @objc private func donePressed(_ sender: UIBarButtonItem) { // When the user presses done, implicitly mark the remaining collisions as resolved (if the finder supports it) // Note: We only do this for dismissal via "Done". If the user uses interactive sheet dismissal, leave the // collisions as-is. databaseStorage.write { writeTx in self.collisionFinder.markCollisionsAsResolved(transaction: writeTx) } collisionDelegate?.nameCollisionControllerDidComplete(self, dismissConversationView: false) } } // MARK: - Contacts extension NameCollisionResolutionViewController: ContactsViewHelperObserver { func shouldShowContactUpdateAction(for address: SignalServiceAddress) -> Bool { guard contactsManagerImpl.isEditingAllowed else { return false } return databaseStorage.read { transaction in return contactsManager.fetchSignalAccount(for: address, transaction: transaction) != nil } } func contactsViewHelperDidUpdateContacts() { updateModel() } } extension NameCollisionResolutionViewController: GroupViewHelperDelegate { func groupViewHelperDidUpdateGroup() { updateModel() } var currentGroupModel: TSGroupModel? { (thread as? TSGroupThread)?.groupModel } var fromViewController: UIViewController? { self } }