Path: blob/main/SignalUI/RecipientPickers/RecipientPickerViewController.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import MessageUI
public import SignalServiceKit
import SwiftUI
public class RecipientPickerViewController: OWSViewController, OWSNavigationChildController {
public enum SelectionMode {
case `default`
// The .blocklist selection mode changes the behavior in a few ways:
//
// - If numbers aren't registered, allow them to be chosen. You may want to
// block someone even if they aren't registered.
//
// - If numbers aren't registered, don't offer to invite them to Signal. If
// you want to block someone, you probably don't want to invite them.
case blocklist
}
public enum GroupsToShow {
case noGroups
case groupsThatUserIsMemberOfWhenSearching
case allGroupsWhenSearching
}
public weak var delegate: RecipientPickerDelegate? {
didSet {
recipientContextMenuHelper.delegate = delegate
}
}
// MARK: Configuration
public var allowsAddByAddress = true
public var shouldHideLocalRecipient = true
public var selectionMode = SelectionMode.default
public var groupsToShow = GroupsToShow.groupsThatUserIsMemberOfWhenSearching
public var shouldShowInvites = false
public var shouldShowAlphabetSlider = true
public var shouldShowNewGroup = false
public var findByPhoneNumberButtonTitle: String?
// MARK: Signal Connections
private var signalConnections = [ComparableDisplayName]()
private var signalConnectionAddresses = Set<SignalServiceAddress>()
// MARK: Picker
public var pickedRecipients: [PickedRecipient] = [] {
didSet {
updateTableContents()
}
}
// MARK: UIViewController
override public func viewDidLoad() {
super.viewDidLoad()
title = OWSLocalizedString("MESSAGE_COMPOSEVIEW_TITLE", comment: "")
updateSignalConnections()
SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
let navigationItem = (parent ?? self).navigationItem
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
// Table View
addChild(tableViewController)
view.addSubview(tableViewController.view)
tableViewController.view.isHidden = isNoContactsModeActive
tableViewController.view.autoPinEdgesToSuperviewEdges()
tableViewController.didMove(toParent: self)
// "No Signal Contacts"
noSignalContactsView.isHidden = !isNoContactsModeActive
view.addSubview(noSignalContactsView)
noSignalContactsView.autoPinWidthToSuperview()
noSignalContactsView.autoPinEdge(toSuperviewEdge: .top)
noSignalContactsView.autoPin(toBottomLayoutGuideOf: self, withInset: 0)
// Pull to Refresh
let refreshControl = UIRefreshControl()
refreshControl.tintColor = .gray
refreshControl.addTarget(self, action: #selector(pullToRefreshPerformed), for: .valueChanged)
tableView.refreshControl = refreshControl
updateTableContents()
applyTheme()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Make sure we have requested contact access at this point if, e.g.
// the user has no messages in their inbox and they choose to compose
// a message.
SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce()
showContactAppropriateViews()
}
override public func themeDidChange() {
super.themeDidChange()
applyTheme()
}
public var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
public var navbarBackgroundColorOverride: UIColor? { tableViewController.tableBackgroundColor }
// MARK: Search
private static let minimumSearchLength = 1
private lazy var searchController: UISearchController = {
let controller = UISearchController(searchResultsController: nil)
controller.obscuresBackgroundDuringPresentation = false
controller.hidesNavigationBarDuringPresentation = false
controller.searchResultsUpdater = self
controller.searchBar.placeholder = OWSLocalizedString(
"SEARCH_BY_NAME_OR_USERNAME_OR_NUMBER_PLACEHOLDER_TEXT",
comment: "Placeholder text indicating the user can search for contacts by name, username, or phone number.",
)
return controller
}()
private var searchText: String {
searchController.searchBar.text?.stripped ?? ""
}
private var lastSearchText: String?
private var lastSearchTask: Task<Void, Never>?
private var _searchResults = Atomic<RecipientSearchResultSet?>(wrappedValue: nil)
private var searchResults: RecipientSearchResultSet? {
get { _searchResults.wrappedValue }
set {
_searchResults.wrappedValue = newValue
updateTableContents()
}
}
private func searchTextDidChange() {
let searchText = self.searchText
guard searchText.count >= Self.minimumSearchLength else {
searchResults = nil
lastSearchText = nil
return
}
guard lastSearchText != searchText else { return }
lastSearchText = searchText
lastSearchTask?.cancel()
lastSearchTask = Task {
do throws(CancellationError) {
let searchResults = try await performSearch(
searchText: searchText,
shouldHideLocalRecipient: self.shouldHideLocalRecipient,
)
if Task.isCancelled {
throw CancellationError()
}
self.searchResults = searchResults
} catch {
// Discard obsolete search results.
return
}
}
}
private nonisolated func performSearch(
searchText: String,
shouldHideLocalRecipient: Bool,
) async throws(CancellationError) -> RecipientSearchResultSet {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
return try databaseStorage.read { tx throws(CancellationError) in
return try FullTextSearcher.shared.searchForRecipients(
searchText: searchText,
includeLocalUser: !shouldHideLocalRecipient,
includeStories: false,
tx: tx,
)
}
}
func clearSearchText() {
searchController.searchBar.text = ""
searchTextDidChange()
}
// MARK: UI
private var isNoContactsModeActive = false {
didSet {
guard oldValue != isNoContactsModeActive else { return }
tableViewController.view.isHidden = isNoContactsModeActive
noSignalContactsView.isHidden = !isNoContactsModeActive
updateTableContents()
}
}
private let collation = UILocalizedIndexedCollation.current()
private lazy var tableViewController: OWSTableViewController2 = {
let viewController = OWSTableViewController2()
viewController.delegate = self
viewController.defaultSeparatorInsetLeading = OWSTableViewController2.cellHInnerMargin
+ CGFloat(AvatarBuilder.smallAvatarSizePoints) + ContactCellView.avatarTextHSpacing
viewController.tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier)
viewController.tableView.register(NonContactTableViewCell.self, forCellReuseIdentifier: NonContactTableViewCell.reuseIdentifier)
viewController.view.setCompressionResistanceVerticalHigh()
viewController.view.setContentHuggingVerticalHigh()
return viewController
}()
private lazy var noSignalContactsView = createNoSignalContactsView()
var tableView: UITableView { tableViewController.tableView }
private func applyTheme() {
tableViewController.tableView.sectionIndexColor = Theme.primaryTextColor
}
// MARK: Context Menu
/// This must be retained for as long as we want to be able
/// to display recipient context menus in this view controller.
private lazy var recipientContextMenuHelper = {
return RecipientContextMenuHelper(
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
blockingManager: SSKEnvironment.shared.blockingManagerRef,
recipientHidingManager: DependenciesBridge.shared.recipientHidingManager,
accountManager: DependenciesBridge.shared.tsAccountManager,
contactsManager: SSKEnvironment.shared.contactManagerRef,
fromViewController: self,
delegate: self.delegate,
)
}()
// MARK: - Fetching Signal Connections
private func updateSignalConnections() {
SSKEnvironment.shared.databaseStorageRef.read { tx in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
// All Signal Connections that we believe are registered. In theory, this
// should include your system contacts, the people you chat with, and Note to Self.
let whitelistedAddresses = Set(SSKEnvironment.shared.profileManagerRef.allWhitelistedRegisteredAddresses(tx: tx))
let blockedAddresses = SSKEnvironment.shared.blockingManagerRef.blockedAddresses(transaction: tx)
let hiddenAddresses = DependenciesBridge.shared.recipientHidingManager.hiddenAddresses(tx: tx)
var resolvedAddresses = Set(whitelistedAddresses).subtracting(blockedAddresses).subtracting(hiddenAddresses)
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
Logger.error("No local identifiers")
return
}
if !shouldHideLocalRecipient {
resolvedAddresses.insert(localIdentifiers.aciAddress)
} else {
resolvedAddresses.remove(localIdentifiers.aciAddress)
}
signalConnections = SSKEnvironment.shared.contactManagerImplRef.sortedComparableNames(for: resolvedAddresses, tx: tx).filter { $0.displayName.hasKnownValue }
signalConnectionAddresses = Set(signalConnections.lazy.map { $0.address })
}
}
// MARK: Table Contents
public func reloadContent() {
updateTableContents()
}
private func updateTableContents() {
AssertIsOnMainThread()
guard !isNoContactsModeActive else {
tableViewController.contents = OWSTableContents()
return
}
let tableContents = OWSTableContents()
// App is killed and restarted when the user changes their contact
// permissions, so no need to "observe" anything to re-render this.
if let reminderSection = contactAccessReminderSection() {
tableContents.add(reminderSection)
}
let staticSection = OWSTableSection()
staticSection.separatorInsetLeading = OWSTableViewController2.cellHInnerMargin + 24 + OWSTableItem.iconSpacing
let isSearching = searchResults != nil
if shouldShowNewGroup, !isSearching {
staticSection.add(OWSTableItem.disclosureItem(
icon: .genericGroup,
withText: OWSLocalizedString(
"NEW_GROUP_BUTTON",
comment: "Label for the 'create new group' button.",
),
actionBlock: { [weak self] in
self?.newGroupButtonPressed()
},
))
}
if allowsAddByAddress, !isSearching {
// Find by username
staticSection.add(OWSTableItem.disclosureItem(
icon: .profileUsername,
withText: OWSLocalizedString(
"NEW_CONVERSATION_FIND_BY_USERNAME",
comment: "A label for the cell that lets you add a new member by their username",
),
actionBlock: { [weak self] in
guard let self else { return }
let viewController = FindByUsernameViewController()
viewController.findByUsernameDelegate = self
self.navigationController?.pushViewController(viewController, animated: true)
},
))
// Find by phone number
staticSection.add(OWSTableItem.disclosureItem(
icon: .phoneNumber,
withText: OWSLocalizedString(
"NEW_CONVERSATION_FIND_BY_PHONE_NUMBER",
comment: "A label the cell that lets you add a new member to a group.",
),
actionBlock: { [weak self] in
guard let self else { return }
let viewController = FindByPhoneNumberViewController(
delegate: self,
buttonText: self.findByPhoneNumberButtonTitle,
requiresRegisteredNumber: self.selectionMode != .blocklist,
)
self.navigationController?.pushViewController(viewController, animated: true)
},
))
}
if staticSection.itemCount > 0 {
tableContents.add(staticSection)
}
// Render any non-contact picked recipients
if !pickedRecipients.isEmpty, !isSearching {
let sectionRecipients = pickedRecipients.filter { recipient in
guard let recipientAddress = recipient.address else { return false }
if signalConnectionAddresses.contains(recipientAddress) {
return false
}
return true
}
if !sectionRecipients.isEmpty {
tableContents.add(OWSTableSection(
title: OWSLocalizedString(
"NEW_GROUP_NON_CONTACTS_SECTION_TITLE",
comment: "a title for the selected section of the 'recipient picker' view.",
),
items: sectionRecipients.map { item(forRecipient: $0) },
))
}
}
if let searchResults {
tableContents.add(sections: contactsSections(for: searchResults))
} else {
// Count the non-collated sections, before we add our collated sections.
// Later we'll need to offset which sections our collation indexes reference
// by this amount. e.g. otherwise the "B" index will reference names starting with "A"
// And the "A" index will reference the static non-collated section(s).
let beforeContactsSectionCount = tableContents.sections.count
tableContents.add(sections: contactsSection())
if shouldShowAlphabetSlider {
tableContents.sectionForSectionIndexTitleBlock = { [weak tableContents, weak self] title, index in
guard let self, let tableContents else { return 0 }
// Offset the collation section to account for the noncollated sections.
let sectionIndex = self.collation.section(forSectionIndexTitle: index) + beforeContactsSectionCount
guard sectionIndex >= 0 else {
// Sentinel in case we change our section ordering in a surprising way.
owsFailDebug("Unexpected negative section index")
return 0
}
guard sectionIndex < tableContents.sections.count else {
// Sentinel in case we change our section ordering in a surprising way.
owsFailDebug("Unexpectedly large index")
return 0
}
return sectionIndex
}
tableContents.sectionIndexTitlesForTableViewBlock = { [weak self] in
guard let self else { return [] }
return self.collation.sectionTitles
}
}
}
// Invite Contacts
if shouldShowInvites, !isSearching, SSKEnvironment.shared.contactManagerImplRef.sharingAuthorization != .denied {
let bottomSection = OWSTableSection(title: OWSLocalizedString(
"INVITE_FRIENDS_CONTACT_TABLE_HEADER",
comment: "Header label above a section for more options for adding contacts",
))
bottomSection.add(OWSTableItem.disclosureItem(
icon: .settingsInvite,
withText: OWSLocalizedString(
"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
comment: "Label for the cell that presents the 'invite contacts' workflow.",
),
actionBlock: { [weak self] in
self?.presentInviteFlow()
},
))
tableContents.add(bottomSection)
}
tableViewController.contents = tableContents
}
// MARK: -
@objc
private func pullToRefreshPerformed(_ refreshControl: UIRefreshControl) {
AssertIsOnMainThread()
Logger.info("Beginning refreshing")
Task { @MainActor in
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegisteredPrimaryDevice {
try? await SSKEnvironment.shared.contactManagerImplRef.userRequestedSystemContactsRefresh().awaitable()
} else {
try? await SSKEnvironment.shared.syncManagerRef.sendAllSyncRequestMessages(timeout: 20).awaitable()
}
Logger.info("ending refreshing")
refreshControl.endRefreshing()
}
}
}
extension RecipientPickerViewController: OWSTableViewControllerDelegate {
public func tableViewWillBeginDragging(_ tableView: UITableView) {
searchController.searchBar.resignFirstResponder()
delegate?.recipientPickerTableViewWillBeginDragging(self)
}
}
extension RecipientPickerViewController: UISearchResultsUpdating {
public func updateSearchResults(for searchController: UISearchController) {
searchTextDidChange()
}
}
extension RecipientPickerViewController: ContactsViewHelperObserver {
public func contactsViewHelperDidUpdateContacts() {
updateSignalConnections()
updateTableContents()
showContactAppropriateViews()
}
}
extension RecipientPickerViewController {
public func groupSection(for searchResults: RecipientSearchResultSet) -> OWSTableSection? {
let groupThreads: [TSGroupThread]
switch groupsToShow {
case .noGroups:
return nil
case .groupsThatUserIsMemberOfWhenSearching:
groupThreads = searchResults.groupThreads.filter { thread in
thread.groupModel.groupMembership.isLocalUserFullMember && !thread.isTerminatedGroup
}
case .allGroupsWhenSearching:
groupThreads = searchResults.groupThreads
}
guard !groupThreads.isEmpty else { return nil }
return OWSTableSection(
title: OWSLocalizedString(
"COMPOSE_MESSAGE_GROUP_SECTION_TITLE",
comment: "Table section header for group listing when composing a new message",
),
items: groupThreads.map {
self.item(forRecipient: PickedRecipient.for(groupThread: $0))
},
)
}
}
// MARK: - Selecting Recipients
private extension RecipientPickerViewController {
private func tryToSelectRecipient(_ recipient: PickedRecipient) {
if let address = recipient.address, address.isLocalAddress, shouldHideLocalRecipient {
owsFailDebug("Trying to select recipient that shouldn't be visible")
return
}
didPrepareToSelectRecipient(recipient)
}
private func didPrepareToSelectRecipient(_ recipient: PickedRecipient) {
AssertIsOnMainThread()
guard let delegate else { return }
delegate.recipientPicker(self, didSelectRecipient: recipient)
}
}
// MARK: - No Contacts
extension RecipientPickerViewController {
private func createNoSignalContactsView() -> UIView {
let heroImageView = UIImageView(image: .init(named: "uiEmptyContact"))
heroImageView.layer.minificationFilter = .trilinear
heroImageView.layer.magnificationFilter = .trilinear
let heroSize = CGFloat.scaleFromIPhone5To7Plus(100, 150)
heroImageView.autoSetDimensions(to: CGSize(square: heroSize))
let titleLabel = UILabel()
titleLabel.text = OWSLocalizedString(
"EMPTY_CONTACTS_LABEL_LINE1",
comment: "Full width label displayed when attempting to compose message",
)
titleLabel.textColor = Theme.primaryTextColor
titleLabel.font = .semiboldFont(ofSize: .scaleFromIPhone5To7Plus(17, 20))
titleLabel.textAlignment = .center
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.numberOfLines = 0
let subtitleLabel = UILabel()
subtitleLabel.text = OWSLocalizedString(
"EMPTY_CONTACTS_LABEL_LINE2",
comment: "Full width label displayed when attempting to compose message",
)
subtitleLabel.textColor = Theme.secondaryTextAndIconColor
subtitleLabel.font = .regularFont(ofSize: .scaleFromIPhone5To7Plus(12, 14))
subtitleLabel.textAlignment = .center
subtitleLabel.lineBreakMode = .byWordWrapping
subtitleLabel.numberOfLines = 0
let headerStack = UIStackView(arrangedSubviews: [
heroImageView,
titleLabel,
subtitleLabel,
])
headerStack.setCustomSpacing(30, after: heroImageView)
headerStack.setCustomSpacing(15, after: titleLabel)
headerStack.axis = .vertical
headerStack.alignment = .center
let buttonStack = UIStackView()
buttonStack.axis = .vertical
buttonStack.alignment = .fill
buttonStack.spacing = 16
func addButton(
title: String,
selector: Selector,
accessibilityIdentifierName: String,
icon: ThemeIcon,
innerIconSize: CGFloat,
) {
let button = UIButton(type: .custom)
button.addTarget(self, action: selector, for: .touchUpInside)
button.accessibilityIdentifier = UIView.accessibilityIdentifier(
in: self,
name: accessibilityIdentifierName,
)
buttonStack.addArrangedSubview(button)
let iconView = OWSTableItem.buildIconInCircleView(
icon: icon,
iconSize: AvatarBuilder.standardAvatarSizePoints,
innerIconSize: innerIconSize,
iconTintColor: Theme.accentBlueColor,
)
iconView.backgroundColor = tableViewController.cellBackgroundColor
let label = UILabel()
label.text = title
label.font = .regularFont(ofSize: 17)
label.textColor = Theme.primaryTextColor
label.lineBreakMode = .byTruncatingTail
let hStack = UIStackView(arrangedSubviews: [iconView, label])
hStack.axis = .horizontal
hStack.alignment = .center
hStack.spacing = 12
hStack.isUserInteractionEnabled = false
button.addSubview(hStack)
hStack.autoPinEdgesToSuperviewEdges()
}
if shouldShowNewGroup {
addButton(
title: OWSLocalizedString(
"NEW_GROUP_BUTTON",
comment: "Label for the 'create new group' button.",
),
selector: #selector(newGroupButtonPressed),
accessibilityIdentifierName: "newGroupButton",
icon: .composeNewGroupLarge,
innerIconSize: 35,
)
}
if allowsAddByAddress {
addButton(
title: OWSLocalizedString(
"NO_CONTACTS_SEARCH_BY_USERNAME",
comment: "Label for a button that lets users search for contacts by username",
),
selector: #selector(hideBackgroundView),
accessibilityIdentifierName: "searchByPhoneNumberButton",
icon: .composeFindByUsernameLarge,
innerIconSize: 40,
)
addButton(
title: OWSLocalizedString(
"NO_CONTACTS_SEARCH_BY_PHONE_NUMBER",
comment: "Label for a button that lets users search for contacts by phone number",
),
selector: #selector(hideBackgroundView),
accessibilityIdentifierName: "searchByPhoneNumberButton",
icon: .composeFindByPhoneNumberLarge,
innerIconSize: 42,
)
}
if shouldShowInvites {
addButton(
title: OWSLocalizedString(
"INVITE_FRIENDS_CONTACT_TABLE_BUTTON",
comment: "Label for the cell that presents the 'invite contacts' workflow.",
),
selector: #selector(presentInviteFlow),
accessibilityIdentifierName: "inviteContactsButton",
icon: .composeInviteLarge,
innerIconSize: 38,
)
}
let stackView = UIStackView(arrangedSubviews: [headerStack, buttonStack])
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 50
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = .init(margin: 20)
let result = UIView()
result.backgroundColor = tableViewController.tableBackgroundColor
result.addSubview(stackView)
stackView.autoPinWidthToSuperview()
stackView.autoVCenterInSuperview()
return result
}
/// Checks if we should show the dedicated "no contacts" view.
///
/// If you don't have any contacts, there's a special UX we'll show to the
/// user that looks a bit nicer than a (mostly) empty table view; that UX
/// doesn't look anything like a normal table view. If you dismiss that
/// view, we'll switch to a normal table view with a row that says "You have
/// no contacts on Signal." This method controls whether or not we show this
/// special UX to the user.
///
/// However, it also works closely in tandem with `noContactsTableSection`
/// and `contactAccessReminderSection`. If this method returns true, those
/// sections can't possibly be shown. If they should be visible, this method
/// must return false. The former is shown in place of the list of contacts,
/// and it's either a loading spinner or the "You have no contacts on
/// Signal." row. The latter is shown at the very top of the recipient
/// picker and may contain a banner if the user has disabled access to their
/// contacts. So, if the user doesn't have any contacts but has also
/// prevented Signal from accessing their contacts, we don't show the
/// special UX and instead allow the banner to be visible.
private func shouldNoContactsModeBeActive() -> Bool {
switch SSKEnvironment.shared.contactManagerImplRef.syncingAuthorization {
case .denied, .restricted:
// Return false so `contactAccessReminderSection` is invoked.
return false
case .limited:
// Return false so `contactAccessReminderSection` is invoked.
return false
case .notAllowed where shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction():
// Return false so `contactAccessReminderSection` is invoked.
return false
case .authorized where !SSKEnvironment.shared.contactManagerImplRef.hasLoadedSystemContacts:
// Return false so `noContactsTableSection` can show a spinner.
return false
case .authorized, .notAllowed:
if !signalConnections.isEmpty {
// Return false if we have any contacts; we want to show them!
return false
}
if SSKEnvironment.shared.preferencesRef.hasDeclinedNoContactsView {
// Return false if the user has explicitly told us to hide the UX.
return false
}
return true
}
}
private func showContactAppropriateViews() {
isNoContactsModeActive = shouldNoContactsModeBeActive()
}
/// Returns a section when there's no contacts to show.
///
/// Works closely with `shouldNoContactsModeBeActive` and therefore might
/// not be invoked even if the user has no contacts.
private func noContactsTableSection() -> OWSTableSection {
switch SSKEnvironment.shared.contactManagerImplRef.syncingAuthorization {
case .denied, .restricted:
return OWSTableSection()
case .limited:
return OWSTableSection()
case .authorized where !SSKEnvironment.shared.contactManagerImplRef.hasLoadedSystemContacts:
return OWSTableSection(items: [loadingContactsTableItem()])
case .authorized, .notAllowed:
return OWSTableSection(items: [noContactsTableItem()])
}
}
/// Returns a section with a banner at the top of the picker.
///
/// Works closely with `shouldNoContactsModeBeActive`.
private func contactAccessReminderSection() -> OWSTableSection? {
let tableItem: OWSTableItem
switch SSKEnvironment.shared.contactManagerImplRef.syncingAuthorization {
case .denied:
tableItem = contactAccessDeniedReminderItem()
case .limited:
if #available(iOS 18, *) {
tableItem = contactAccessLimitedReminderItem()
} else {
return nil
}
case .restricted:
// TODO: We don't show a reminder when the user isn't allowed to give
// contacts permission. Should we?
return nil
case .authorized:
return nil
case .notAllowed:
guard shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction() else {
return nil
}
tableItem = contactAccessNotAllowedReminderItem()
}
return OWSTableSection(items: [tableItem])
}
private func noContactsTableItem() -> OWSTableItem {
return OWSTableItem.softCenterLabel(
withText: OWSLocalizedString(
"SETTINGS_BLOCK_LIST_NO_CONTACTS",
comment: "A label that indicates the user has no Signal contacts that they haven't blocked.",
),
)
}
private func loadingContactsTableItem() -> OWSTableItem {
let cell = OWSTableItem.newCell()
let activityIndicatorView = UIActivityIndicatorView(style: .medium)
cell.contentView.addSubview(activityIndicatorView)
activityIndicatorView.startAnimating()
activityIndicatorView.autoCenterInSuperview()
activityIndicatorView.setCompressionResistanceHigh()
activityIndicatorView.setContentHuggingHigh()
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "loading")
let tableItem = OWSTableItem(customCellBlock: { cell })
tableItem.customRowHeight = 40
return tableItem
}
private func contactAccessDeniedReminderItem() -> OWSTableItem {
return OWSTableItem(customCellBlock: {
let cell = UITableViewCell()
cell.selectionStyle = .none
cell.backgroundColor = .clear
let reminderView = ReminderView(
style: .warning,
text: OWSLocalizedString(
"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION",
comment: "Multi-line label explaining why compose-screen contact picker is empty.",
),
actionTitle: OWSLocalizedString(
"COMPOSE_SCREEN_MISSING_CONTACTS_CTA",
comment: "Button to open settings from an empty compose-screen contact picker.",
),
tapAction: { CurrentAppContext().openSystemSettings() },
renderInCell: true,
)
cell.contentView.addSubview(reminderView)
reminderView.autoPinEdgesToSuperviewEdges()
return cell
})
}
@available(iOS 18, *)
private func contactAccessLimitedReminderItem() -> OWSTableItem {
return OWSTableItem(customCellBlock: {
let cell = ContactAccessLimitedReminderTableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
ContactAccessLimitedReminderView {
Task {
// Fetch all contacts the app has access to.
try? await SSKEnvironment.shared.contactManagerImplRef.userRequestedSystemContactsRefresh().asVoid().awaitable()
}
}
}
return cell
})
}
private static let keyValueStore = KeyValueStore(collection: "RecipientPicker.contactAccess")
private static let showNotAllowedReminderKey = "shouldShowNotAllowedReminder"
private func shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction() -> Bool {
SSKEnvironment.shared.databaseStorageRef.read {
Self.keyValueStore.getBool(Self.showNotAllowedReminderKey, defaultValue: true, transaction: $0)
}
}
private func hideShowContactAccessNotAllowedReminderItem() {
SSKEnvironment.shared.databaseStorageRef.write {
Self.keyValueStore.setBool(false, key: Self.showNotAllowedReminderKey, transaction: $0)
}
reloadContent()
}
private func contactAccessNotAllowedReminderItem() -> OWSTableItem {
return OWSTableItem(customCellBlock: {
ContactReminderTableViewCell(
learnMoreAction: { [weak self] in
guard let self else { return }
ContactsViewHelper.presentContactAccessNotAllowedLearnMore(from: self)
},
dismissAction: { [weak self] in
self?.hideShowContactAccessNotAllowedReminderItem()
},
)
})
}
@objc
private func newGroupButtonPressed() {
delegate?.recipientPickerNewGroupButtonWasPressed()
}
@objc
private func hideBackgroundView() {
SSKEnvironment.shared.preferencesRef.setHasDeclinedNoContactsView(true)
showContactAppropriateViews()
}
@objc
private func presentInviteFlow() {
let inviteFlow = InviteFlow(presentingViewController: self)
inviteFlow.present(isAnimated: true, completion: nil)
}
}
// MARK: - Contacts, Connections, & Groups
extension RecipientPickerViewController {
private func contactsSection() -> [OWSTableSection] {
guard !signalConnections.isEmpty else {
return [noContactsTableSection()]
}
// All contacts in one section
guard shouldShowAlphabetSlider else {
return [OWSTableSection(
title: OWSLocalizedString(
"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
comment: "Table section header for contact listing when composing a new message",
),
items: signalConnections.map { item(forRecipient: PickedRecipient.for(address: $0.address)) },
)]
}
var collatedSignalConnections = collation.sectionTitles.map { _ in return [ComparableDisplayName]() }
for signalConnection in signalConnections {
let section = collation.section(
for: CollatableComparableDisplayName(signalConnection),
collationStringSelector: #selector(CollatableComparableDisplayName.collationString),
)
guard section >= 0 else {
continue
}
collatedSignalConnections[section].append(signalConnection)
}
let contactSections = collatedSignalConnections.enumerated().map { index, signalConnections in
// Don't show empty sections.
// To accomplish this we add a section with a blank title rather than omitting the section altogether,
// in order for section indexes to match up correctly
if signalConnections.isEmpty {
return OWSTableSection()
}
return OWSTableSection(
title: collation.sectionTitles[index].uppercased(),
items: signalConnections.map { item(forRecipient: PickedRecipient.for(address: $0.address)) },
)
}
return contactSections
}
private func contactsSections(for searchResults: RecipientSearchResultSet) -> [OWSTableSection] {
AssertIsOnMainThread()
var sections = [OWSTableSection]()
// Contacts, with blocked contacts and hidden recipients removed.
var matchedAccountPhoneNumbers = Set<String>()
var contactsSectionItems = [OWSTableItem]()
SSKEnvironment.shared.databaseStorageRef.read { tx in
for recipientAddress in searchResults.contactResults.map({ $0.recipientAddress }) {
if let phoneNumber = recipientAddress.phoneNumber {
matchedAccountPhoneNumbers.insert(phoneNumber)
}
contactsSectionItems.append(item(forRecipient: PickedRecipient.for(address: recipientAddress)))
}
}
if !contactsSectionItems.isEmpty {
sections.append(OWSTableSection(
title: OWSLocalizedString(
"COMPOSE_MESSAGE_CONTACT_SECTION_TITLE",
comment: "Table section header for contact listing when composing a new message",
),
items: contactsSectionItems,
))
}
if let groupSection = groupSection(for: searchResults) {
sections.append(groupSection)
}
if let findByNumberSection = findByNumberSection(for: searchResults, skipping: matchedAccountPhoneNumbers) {
sections.append(findByNumberSection)
}
if let usernameSection = findByUsernameSection(for: searchResults) {
sections.append(usernameSection)
}
guard !sections.isEmpty else {
// No Search Results
return [
OWSTableSection(items: [
OWSTableItem.softCenterLabel(withText: OWSLocalizedString(
"SETTINGS_BLOCK_LIST_NO_SEARCH_RESULTS",
comment: "A label that indicates the user's search has no matching results.",
)),
]),
]
}
return sections
}
private func item(forRecipient recipient: PickedRecipient) -> OWSTableItem {
switch recipient.identifier {
case .address(let address):
return OWSTableItem(
dequeueCellBlock: { [weak self] tableView in
self?.addressCell(for: address, recipient: recipient, tableView: tableView) ?? UITableViewCell()
},
actionBlock: { [weak self] in
self?.tryToSelectRecipient(recipient)
},
contextMenuActionProvider: recipientContextMenuHelper.actionProvider(address: address),
)
case .group(let groupThread):
return OWSTableItem(
customCellBlock: { [weak self] in
self?.groupCell(for: groupThread, recipient: recipient) ?? UITableViewCell()
},
actionBlock: { [weak self] in
self?.tryToSelectRecipient(recipient)
},
contextMenuActionProvider: recipientContextMenuHelper.actionProvider(groupThread: groupThread),
)
}
}
private func addressCell(for address: SignalServiceAddress, recipient: PickedRecipient, tableView: UITableView) -> UITableViewCell? {
guard let cell = tableView.dequeueReusableCell(ContactTableViewCell.self) else { return nil }
SSKEnvironment.shared.databaseStorageRef.read { transaction in
let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .noteToSelf)
if let delegate {
cell.selectionStyle = delegate.recipientPicker(self, selectionStyleForRecipient: recipient, transaction: transaction)
if let accessoryView = delegate.recipientPicker(self, accessoryViewForRecipient: recipient, transaction: transaction) {
configuration.accessoryView = accessoryView
} else {
let accessoryMessage = delegate.recipientPicker(self, accessoryMessageForRecipient: recipient, transaction: transaction)
configuration.accessoryMessage = accessoryMessage
}
if let attributedSubtitle = delegate.recipientPicker(self, attributedSubtitleForRecipient: recipient, transaction: transaction) {
configuration.attributedSubtitle = attributedSubtitle
}
configuration.allowUserInteraction = delegate.recipientPicker(self, shouldAllowUserInteractionForRecipient: recipient, transaction: transaction)
let isSystemContact = SSKEnvironment.shared.contactManagerRef.fetchSignalAccount(for: address, transaction: transaction) != nil
configuration.shouldShowContactIcon = isSystemContact
}
cell.configure(configuration: configuration, transaction: transaction)
}
return cell
}
private func groupCell(for groupThread: TSGroupThread, recipient: PickedRecipient) -> UITableViewCell? {
let cell = GroupTableViewCell()
if let delegate {
SSKEnvironment.shared.databaseStorageRef.read { tx in
cell.selectionStyle = delegate.recipientPicker(self, selectionStyleForRecipient: recipient, transaction: tx)
cell.accessoryMessage = delegate.recipientPicker(self, accessoryMessageForRecipient: recipient, transaction: tx)
cell.customAccessoryView = delegate.recipientPicker(self, accessoryViewForRecipient: recipient, transaction: tx)?.accessoryView
}
}
cell.configure(thread: groupThread)
return cell
}
}
// MARK: - Find by Number
struct PhoneNumberFinder {
let localNumber: String?
let contactDiscoveryManager: ContactDiscoveryManager
let phoneNumberUtil: PhoneNumberUtil
enum SearchResult {
/// This e164 has already been validated by libPhoneNumber.
case valid(validE164: String)
/// This e164 consists of arbitrary user-provided text that needs to be
/// validated before fetching it from CDS.
case maybeValid(maybeValidE164: String)
var maybeValidE164: String {
switch self {
case .valid(validE164: let validE164):
return validE164
case .maybeValid(maybeValidE164: let maybeValidE164):
return maybeValidE164
}
}
}
/// For a given search term, extract potential phone number matches.
///
/// We consider phone number matches that libPhoneNumber thinks may be
/// valid, based on a fuzzy matching algorithm and the user's current phone
/// number. It's possible to receive multiple matches.
///
/// For example, if your current number has the +1 calling code and you
/// enter "521 555 0100", you'll see three results:
/// - +1 521-555-0100
/// - +52 15 5501 00
/// - +52 55 5010 0
///
/// We also consider arbitrary sequences of digits entered by the user. We
/// wait to validate these until the user taps them. This improves the UX
/// and helps make the feature more discoverable.
///
/// - Parameter searchText:
/// Arbitrary text provided by the user. It could be "cat", the empty
/// string, or something that looks roughly like a phone number. If this
/// parameter contains fewer than 3 characters, an empty array is
/// returned.
///
/// - Returns: Zero, one, or many matches.
func parseResults(for searchText: String) -> [SearchResult] {
guard searchText.count >= 3 else {
return []
}
// Check for valid libPhoneNumber results.
let uniqueResults = OrderedSet(
phoneNumberUtil.parsePhoneNumbers(
userSpecifiedText: searchText,
localPhoneNumber: localNumber ?? "",
).lazy.compactMap { self.validE164(from: $0) },
)
if !uniqueResults.isEmpty {
return uniqueResults.orderedMembers.map { .valid(validE164: $0) }
}
// Otherwise, show a potentially-invalid number that we'll validate if the
// user tries to select it.
if let maybeValidE164 = parseFakeSearchPhoneNumber(for: searchText) {
return [.maybeValid(maybeValidE164: maybeValidE164)]
}
return []
}
private func parseFakeSearchPhoneNumber(for searchText: String) -> String? {
let filteredValue = searchText.filteredAsE164
let potentialE164: String
if filteredValue.hasPrefix("+") {
potentialE164 = filteredValue
} else if
let localNumber,
let callingCode = phoneNumberUtil.parseE164(localNumber)?.getCallingCode()
{
potentialE164 = "+\(callingCode)\(filteredValue)"
} else {
owsFailDebug("No localNumber")
return nil
}
// Stop showing results after 20 characters. A 3-digit country code (4
// characters, including "+") and a 15-digit phone number would be 19
// characters. Allow for one extra accidental character, even though a
// 20-digit number should always fail to parse.
guard (3...20).contains(potentialE164.count) else {
return nil
}
// Allow only symbols, digits, and whitespace. The `filterE164()` call
// above will keep only "+" and ASCII digits, but the user may try to
// format the number themselves, or they may paste a number formatted
// elsewhere. If the user types a letter, this result will disappear.
var allowedCharacters = CharacterSet(charactersIn: "+0123456789")
allowedCharacters.formUnion(.whitespaces)
allowedCharacters.formUnion(.punctuationCharacters) // allow "(", ")", "-", etc.
guard searchText.rangeOfCharacter(from: allowedCharacters.inverted) == nil else {
return nil
}
return potentialE164
}
private func validE164(from phoneNumber: PhoneNumber) -> String? {
return E164(phoneNumber.e164)?.stringValue
}
enum LookupResult {
/// The phone number was found on CDS.
case success(SignalRecipient)
/// The phone number is valid but doesn't exist on CDS. Perhaps phone number
/// discovery is disabled, or perhaps the account isn't registered.
case notFound(validE164: String)
/// The phone number isn't valid, so we didn't even send a request to CDS to check.
case notValid(invalidE164: String)
}
func lookUp(phoneNumber searchResult: SearchResult) async throws -> LookupResult {
let validE164ToLookUp: String
switch searchResult {
case .valid(validE164: let validE164):
validE164ToLookUp = validE164
case .maybeValid(maybeValidE164: let maybeValidE164):
guard
let phoneNumber = phoneNumberUtil.parsePhoneNumber(userSpecifiedText: maybeValidE164),
let validE164 = validE164(from: phoneNumber)
else {
return .notValid(invalidE164: maybeValidE164)
}
validE164ToLookUp = validE164
}
let signalRecipients = try await contactDiscoveryManager.lookUp(phoneNumbers: [validE164ToLookUp], mode: .oneOffUserRequest)
if let signalRecipient = signalRecipients.first {
return .success(signalRecipient)
} else {
return .notFound(validE164: validE164ToLookUp)
}
}
}
extension RecipientPickerViewController {
private func findByNumberCell(for phoneNumber: String, tableView: UITableView) -> UITableViewCell? {
guard let cell = tableView.dequeueReusableCell(NonContactTableViewCell.self) else { return nil }
cell.configureWithPhoneNumber(phoneNumber)
return cell
}
public func findByNumberSection(
for searchResults: RecipientSearchResultSet,
skipping alreadyMatchedPhoneNumbers: Set<String>,
) -> OWSTableSection? {
let phoneNumberFinder = PhoneNumberFinder(
localNumber: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber,
contactDiscoveryManager: SSKEnvironment.shared.contactDiscoveryManagerRef,
phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef,
)
var phoneNumberResults = phoneNumberFinder.parseResults(for: searchResults.searchText)
// Don't show phone numbers that are visible in other sections.
phoneNumberResults.removeAll { alreadyMatchedPhoneNumbers.contains($0.maybeValidE164) }
// Don't show the user's own number if they can't select it.
if shouldHideLocalRecipient, let localNumber = phoneNumberFinder.localNumber {
phoneNumberResults.removeAll { localNumber == $0.maybeValidE164 }
}
guard !phoneNumberResults.isEmpty else {
return nil
}
return OWSTableSection(
title: OWSLocalizedString(
"COMPOSE_MESSAGE_PHONE_NUMBER_SEARCH_SECTION_TITLE",
comment: "Table section header for phone number search when composing a new message",
),
items: phoneNumberResults.map { phoneNumberResult in
return OWSTableItem(
dequeueCellBlock: { [weak self] tableView in
let e164 = phoneNumberResult.maybeValidE164
return self?.findByNumberCell(for: e164, tableView: tableView) ?? UITableViewCell()
},
actionBlock: { [weak self] in
self?.findByNumber(phoneNumberResult, using: phoneNumberFinder)
},
)
},
)
}
/// Performs a lookup for an unknown number entered by the user.
///
/// - If the number is found, the recipient will be selected. (The
/// definition of "selected" depends on whether you're on the Compose
/// screen, Add Group Members screen, etc.)
///
/// - If the number isn't found, the behavior depends on `selectionMode`. If
/// you're trying to block someone, we'll allow the number to be blocked.
/// Otherwise, you'll be told that the number isn't registered.
///
/// - If the number isn't valid, you'll be told that it's not valid.
///
/// - Parameter phoneNumberResult: The search result the user tapped.
private func findByNumber(_ phoneNumberResult: PhoneNumberFinder.SearchResult, using finder: PhoneNumberFinder) {
ModalActivityIndicatorViewController.present(
fromViewController: self,
title: CommonStrings.searchingModal,
) { modal in
do {
let lookupResult = try await finder.lookUp(phoneNumber: phoneNumberResult)
modal.dismissIfNotCanceled {
self.handlePhoneNumberLookupResult(lookupResult)
}
} catch {
modal.dismissIfNotCanceled {
OWSActionSheets.showErrorAlert(message: error.userErrorDescription)
}
}
}
}
private func handlePhoneNumberLookupResult(_ lookupResult: PhoneNumberFinder.LookupResult) {
switch (selectionMode, lookupResult) {
case (_, .success(let signalRecipient)):
// If the lookup was successful, select the recipient.
tryToSelectRecipient(.for(address: signalRecipient.address))
case (.blocklist, .notFound(validE164: let validE164)):
// If we're trying to block an unregistered user, allow it.
tryToSelectRecipient(.for(address: SignalServiceAddress(phoneNumber: validE164)))
case (.`default`, .notFound(validE164: let validE164)):
// Otherwise, if we're trying to contact someone, offer to invite them.
Self.presentSMSInvitationSheet(for: validE164, fromViewController: self)
case (_, .notValid(invalidE164: let invalidE164)):
// If the number isn't valid, show an error so the user can fix it.
presentInvalidNumberSheet(for: invalidE164)
}
}
public static func presentSMSInvitationSheet(
for phoneNumber: String,
fromViewController viewController: UIViewController,
dismissalDelegate: (any SheetDismissalDelegate)? = nil,
) {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"RECIPIENT_PICKER_INVITE_TITLE",
comment: "Alert title. Shown after selecting a phone number that isn't a Signal user.",
),
message: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"RECIPIENT_PICKER_INVITE_MESSAGE",
comment: "Alert text. Shown after selecting a phone number that isn't a Signal user.",
),
PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber),
),
)
actionSheet.addAction(OWSActionSheets.cancelAction)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"RECIPIENT_PICKER_INVITE_ACTION",
comment: "Button. Shown after selecting a phone number that isn't a Signal user. Tapping the button will open a view that allows the user to send an SMS message to specified phone number.",
),
style: .default,
handler: { [weak viewController] action in
guard let viewController else { return }
guard MFMessageComposeViewController.canSendText() else {
OWSActionSheets.showErrorAlert(message: InviteFlow.unsupportedFeatureMessage, fromViewController: viewController)
return
}
let inviteFlow = InviteFlow(presentingViewController: viewController)
inviteFlow.sendSMSTo(phoneNumbers: [phoneNumber])
},
))
actionSheet.dismissalDelegate = dismissalDelegate
viewController.presentActionSheet(actionSheet)
}
private func presentInvalidNumberSheet(for phoneNumber: String) {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"RECIPIENT_PICKER_INVALID_NUMBER_TITLE",
comment: "Alert title. Shown after selecting a phone number that isn't valid.",
),
message: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"RECIPIENT_PICKER_INVALID_NUMBER_MESSAGE",
comment: "Alert text. Shown after selecting a phone number that isn't valid.",
),
PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(phoneNumber),
),
)
actionSheet.addAction(OWSActionSheets.okayAction)
presentActionSheet(actionSheet)
}
}
// MARK: - FindByPhoneNumberDelegate
// ^^ This refers to the *separate* "Find by Phone Number" row that you can tap.
extension RecipientPickerViewController: FindByPhoneNumberDelegate {
public func findByPhoneNumber(
_ findByPhoneNumber: FindByPhoneNumberViewController,
didSelectAddress address: SignalServiceAddress,
) {
owsAssertDebug(address.isValid)
tryToSelectRecipient(.for(address: address))
}
}
extension RecipientPickerViewController: FindByUsernameDelegate {
func findByUsername(address: SignalServiceAddress) {
owsAssertDebug(address.isValid)
tryToSelectRecipient(.for(address: address))
}
var shouldShowQRCodeButton: Bool {
delegate?.shouldShowQRCodeButton ?? false
}
func openQRCodeScanner() {
delegate?.openUsernameQRCodeScanner()
}
}
// MARK: - Find by Username
extension RecipientPickerViewController {
private func parsePossibleSearchUsername(for searchText: String) -> String? {
let username = FindByUsername.preParseUsername(searchText)
guard let firstCharacter = username.first else {
// Don't show username results -- the user hasn't searched for anything
return nil
}
guard firstCharacter != "+" else {
// Don't show username results -- assume this is a phone number
return nil
}
guard !("0"..."9").contains(firstCharacter) else {
// Don't show username results -- assume this is a phone number
return nil
}
return username
}
private func findByUsernameCell(for username: String, tableView: UITableView) -> UITableViewCell? {
guard let cell = tableView.dequeueReusableCell(NonContactTableViewCell.self) else { return nil }
cell.configureWithUsername(username)
return cell
}
private func findByUsernameSection(for searchResults: RecipientSearchResultSet) -> OWSTableSection? {
guard let username = parsePossibleSearchUsername(for: searchResults.searchText) else {
return nil
}
let tableItem = OWSTableItem(
dequeueCellBlock: { [weak self] tableView in
self?.findByUsernameCell(for: username, tableView: tableView) ?? UITableViewCell()
},
actionBlock: { [weak self] in
self?.findByUsername(username)
},
)
return OWSTableSection(
title: OWSLocalizedString(
"COMPOSE_MESSAGE_USERNAME_SEARCH_SECTION_TITLE",
comment: "Table section header for username search when composing a new message",
),
items: [tableItem],
)
}
private func findByUsername(_ username: String) {
Task {
guard
let aci = await UsernameQuerier().queryForUsername(
username: username,
fromViewController: self,
)
else {
return
}
tryToSelectRecipient(.for(address: SignalServiceAddress(aci)))
}
}
}
// MARK: - ContactAccessLimitedReminderTableViewCell
class ContactAccessLimitedReminderTableViewCell: UITableViewCell, CustomBackgroundColorCell {
func customBackgroundColor(forceDarkMode: Bool) -> UIColor {
Theme.isDarkThemeEnabled ? .ows_gray80 : .ows_gray05
}
func customSelectedBackgroundColor(forceDarkMode: Bool) -> UIColor {
customBackgroundColor(forceDarkMode: forceDarkMode)
}
}