Signal-iOS/Signal/src/ViewControllers/ThreadSettings/NameCollisionResolutionViewController.swift

316 lines
13 KiB
Swift

//
// 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 }
}