If all your contacts were blocked, we might not show the special “no contacts” mode even though it should have been enabled.
772 lines
32 KiB
Swift
772 lines
32 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import MessageUI
|
|
import SignalMessaging
|
|
import SignalServiceKit
|
|
|
|
extension RecipientPickerViewController {
|
|
@objc(groupSectionForSearchResults:)
|
|
public func groupSection(for searchResults: ComposeScreenSearchResultSet) -> OWSTableSection? {
|
|
let groupThreads: [TSGroupThread]
|
|
switch groupsToShow {
|
|
case .showNoGroups:
|
|
return nil
|
|
case .showGroupsThatUserIsMemberOfWhenSearching:
|
|
groupThreads = searchResults.groupThreads.filter { thread in
|
|
thread.isLocalUserFullMember
|
|
}
|
|
case .showAllGroupsWhenSearching:
|
|
groupThreads = searchResults.groupThreads
|
|
}
|
|
|
|
guard !groupThreads.isEmpty else { return nil }
|
|
|
|
return OWSTableSection(
|
|
title: NSLocalizedString(
|
|
"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 {
|
|
func tryToSelectRecipient(_ recipient: PickedRecipient) {
|
|
if let address = recipient.address, address.isLocalAddress, shouldHideLocalRecipient {
|
|
owsFailDebug("Trying to select recipient that shouldn't be visible")
|
|
return
|
|
}
|
|
if shouldUseAsyncSelection {
|
|
prepareToSelectRecipient(recipient)
|
|
} else {
|
|
didPrepareToSelectRecipient(recipient)
|
|
}
|
|
}
|
|
|
|
private func prepareToSelectRecipient(_ recipient: PickedRecipient) {
|
|
guard let delegate = delegate else { return }
|
|
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modal in
|
|
firstly {
|
|
delegate.recipientPicker(self, prepareToSelectRecipient: recipient)
|
|
}.done(on: .main) { [weak self] _ in
|
|
modal.dismiss {
|
|
self?.didPrepareToSelectRecipient(recipient)
|
|
}
|
|
}.catch(on: .main) { error in
|
|
owsFailDebugUnlessNetworkFailure(error)
|
|
modal.dismiss {
|
|
OWSActionSheets.showErrorAlert(message: error.userErrorDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func didPrepareToSelectRecipient(_ recipient: PickedRecipient) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let delegate = delegate else { return }
|
|
|
|
let recipientPickerRecipientState = delegate.recipientPicker(self, getRecipientState: recipient)
|
|
guard recipientPickerRecipientState == .canBeSelected else {
|
|
showErrorAlert(recipientPickerRecipientState: recipientPickerRecipientState)
|
|
return
|
|
}
|
|
|
|
delegate.recipientPicker(self, didSelectRecipient: recipient)
|
|
}
|
|
|
|
private func showErrorAlert(recipientPickerRecipientState: RecipientPickerRecipientState) {
|
|
let errorMessage: String
|
|
switch recipientPickerRecipientState {
|
|
case .duplicateGroupMember:
|
|
errorMessage = OWSLocalizedString(
|
|
"GROUPS_ERROR_MEMBER_ALREADY_IN_GROUP",
|
|
comment: "Error message indicating that a member can't be added to a group because they are already in the group."
|
|
)
|
|
case .userAlreadyInBlocklist:
|
|
errorMessage = OWSLocalizedString(
|
|
"BLOCK_LIST_ERROR_USER_ALREADY_IN_BLOCKLIST",
|
|
comment: "Error message indicating that a user can't be blocked because they are already blocked."
|
|
)
|
|
case .conversationAlreadyInBlocklist:
|
|
errorMessage = OWSLocalizedString(
|
|
"BLOCK_LIST_ERROR_CONVERSATION_ALREADY_IN_BLOCKLIST",
|
|
comment: "Error message indicating that a conversation can't be blocked because they are already blocked."
|
|
)
|
|
case .canBeSelected, .unknownError:
|
|
owsFailDebug("Unexpected value.")
|
|
errorMessage = OWSLocalizedString(
|
|
"RECIPIENT_PICKER_ERROR_USER_CANNOT_BE_SELECTED",
|
|
comment: "Error message indicating that a user can't be selected."
|
|
)
|
|
}
|
|
OWSActionSheets.showErrorAlert(message: errorMessage)
|
|
}
|
|
}
|
|
|
|
// MARK: - No Contacts
|
|
|
|
extension RecipientPickerViewController {
|
|
@objc
|
|
func filteredSignalAccounts() -> [SignalAccount] {
|
|
Array(lazyFilteredSignalAccounts())
|
|
}
|
|
|
|
/// Fetches the Signal Connections for the recipient picker.
|
|
///
|
|
/// Excludes the local address (if `shouldHideLocalRecipient` is true) as
|
|
/// well as any addresses that have been blocked.
|
|
private func lazyFilteredSignalAccounts() -> LazyFilterSequence<[SignalAccount]> {
|
|
let allSignalAccounts = contactsViewHelper.signalAccounts(includingLocalUser: !shouldHideLocalRecipient)
|
|
lazy var blockedAddresses = databaseStorage.read { blockingManager.blockedAddresses(transaction: $0) }
|
|
return allSignalAccounts.lazy.filter { !blockedAddresses.contains($0.recipientAddress) }
|
|
}
|
|
|
|
/// 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.
|
|
@objc
|
|
func shouldNoContactsModeBeActive() -> Bool {
|
|
switch contactsManagerImpl.editingAuthorization {
|
|
case .denied, .restricted:
|
|
// Return false so `contactAccessReminderSection` is invoked.
|
|
return false
|
|
case .authorized where !contactsViewHelper.hasUpdatedContactsAtLeastOnce:
|
|
// Return false so `noContactsTableSection` can show a spinner.
|
|
return false
|
|
case .authorized:
|
|
if !lazyFilteredSignalAccounts().isEmpty {
|
|
// Return false if we have any contacts; we want to show them!
|
|
return false
|
|
}
|
|
if preferences.hasDeclinedNoContactsView() {
|
|
// Return false if the user has explicitly told us to hide the UX.
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
@objc
|
|
func noContactsTableSection() -> OWSTableSection {
|
|
switch contactsManagerImpl.editingAuthorization {
|
|
case .denied, .restricted:
|
|
return OWSTableSection()
|
|
case .authorized where !contactsViewHelper.hasUpdatedContactsAtLeastOnce:
|
|
return OWSTableSection(items: [loadingContactsTableItem()])
|
|
case .authorized:
|
|
return OWSTableSection(items: [noContactsTableItem()])
|
|
}
|
|
}
|
|
|
|
/// Returns a section with a banner at the top of the picker.
|
|
///
|
|
/// Works closely with `shouldNoContactsModeBeActive`.
|
|
@objc
|
|
func contactAccessReminderSection() -> OWSTableSection? {
|
|
let tableItem: OWSTableItem
|
|
switch contactsManagerImpl.editingAuthorization {
|
|
case .denied:
|
|
tableItem = contactAccessDeniedReminderItem()
|
|
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
|
|
}
|
|
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."
|
|
),
|
|
customRowHeight: UITableView.automaticDimension
|
|
)
|
|
}
|
|
|
|
private func loadingContactsTableItem() -> OWSTableItem {
|
|
let cell = OWSTableItem.newCell()
|
|
|
|
let activityIndicatorView = UIActivityIndicatorView(style: .gray)
|
|
cell.contentView.addSubview(activityIndicatorView)
|
|
activityIndicatorView.startAnimating()
|
|
activityIndicatorView.autoCenterInSuperview()
|
|
activityIndicatorView.setCompressionResistanceHigh()
|
|
activityIndicatorView.setContentHuggingHigh()
|
|
|
|
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "loading")
|
|
|
|
return OWSTableItem(
|
|
customCellBlock: { cell },
|
|
customRowHeight: 40
|
|
)
|
|
}
|
|
|
|
private func contactAccessDeniedReminderItem() -> OWSTableItem {
|
|
return OWSTableItem(customCellBlock: {
|
|
let reminderView = ReminderView.nag(
|
|
text: OWSLocalizedString(
|
|
"COMPOSE_SCREEN_MISSING_CONTACTS_PERMISSION",
|
|
comment: "Multi-line label explaining why compose-screen contact picker is empty."),
|
|
tapAction: { CurrentAppContext().openSystemSettings() }
|
|
)
|
|
|
|
let cell = OWSTableItem.newCell()
|
|
cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "missing_contacts")
|
|
cell.contentView.addSubview(reminderView)
|
|
reminderView.autoPinEdgesToSuperviewEdges()
|
|
|
|
return cell
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: - Contacts, Connections, & Groups
|
|
|
|
extension RecipientPickerViewController {
|
|
@objc
|
|
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)
|
|
}
|
|
)
|
|
case .group(let groupThread):
|
|
return OWSTableItem(
|
|
customCellBlock: { [weak self] in
|
|
self?.groupCell(for: groupThread, recipient: recipient) ?? UITableViewCell()
|
|
},
|
|
actionBlock: { [weak self] in
|
|
self?.tryToSelectRecipient(recipient)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func addressCell(for address: SignalServiceAddress, recipient: PickedRecipient, tableView: UITableView) -> UITableViewCell? {
|
|
guard let cell = tableView.dequeueReusableCell(ContactTableViewCell.self) else { return nil }
|
|
if let delegate, delegate.recipientPicker(self, getRecipientState: recipient) != .canBeSelected {
|
|
cell.selectionStyle = .none
|
|
}
|
|
databaseStorage.read { transaction in
|
|
let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .noteToSelf)
|
|
if let delegate {
|
|
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
|
|
}
|
|
}
|
|
cell.configure(configuration: configuration, transaction: transaction)
|
|
}
|
|
return cell
|
|
}
|
|
|
|
private func groupCell(for groupThread: TSGroupThread, recipient: PickedRecipient) -> UITableViewCell? {
|
|
let cell = GroupTableViewCell()
|
|
|
|
if let delegate {
|
|
if delegate.recipientPicker(self, getRecipientState: recipient) != .canBeSelected {
|
|
cell.selectionStyle = .none
|
|
}
|
|
|
|
cell.accessoryMessage = databaseStorage.read {
|
|
delegate.recipientPicker(self, accessoryMessageForRecipient: recipient, transaction: $0)
|
|
}
|
|
}
|
|
|
|
cell.configure(thread: groupThread)
|
|
|
|
return cell
|
|
}
|
|
}
|
|
|
|
// MARK: - Find by Number
|
|
|
|
struct PhoneNumberFinder {
|
|
var localNumber: String?
|
|
var contactDiscoveryManager: ContactDiscoveryManager
|
|
|
|
/// A list of all [+1, +20, ..., +N] known calling codes.
|
|
static let validCallingCodes: [String] = {
|
|
Set(PhoneNumberUtil.countryCodes(forSearchTerm: nil).lazy.compactMap {
|
|
PhoneNumberUtil.callingCode(fromCountryCode: $0)
|
|
}).sorted()
|
|
}()
|
|
|
|
/// Extracts the calling code (e.g., "+1") from an e164.
|
|
///
|
|
/// Calling codes are defined such that only one prefix match should be
|
|
/// possible. For example, "+1" is a valid prefix, but "+12" and "+123"
|
|
/// aren't; if a number starts with "+1", the calling code is "+1".
|
|
/// Similarly, "+351" and "+352" are valid prefixes, but "+35" isn't.
|
|
///
|
|
/// - Returns:
|
|
/// The calling code (starting with "+") if the provided e164 starts
|
|
/// with a valid calling code.
|
|
private static func callingCode(for e164: String) -> String? {
|
|
owsAssertDebug(e164.hasPrefix("+"))
|
|
return validCallingCodes.first { e164.hasPrefix($0) }
|
|
}
|
|
|
|
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(
|
|
PhoneNumber.tryParsePhoneNumbers(
|
|
fromUserSpecifiedText: searchText,
|
|
clientPhoneNumber: 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 as NSString).filterAsE164()
|
|
|
|
let potentialE164: String
|
|
if filteredValue.hasPrefix("+") {
|
|
potentialE164 = filteredValue
|
|
} else if let localNumber, let callingCode = Self.callingCode(for: localNumber) {
|
|
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? {
|
|
let e164 = phoneNumber.toE164()
|
|
guard let callingCode = Self.callingCode(for: e164) else {
|
|
return nil
|
|
}
|
|
guard (1...15).contains(e164.count - callingCode.count) else {
|
|
return nil
|
|
}
|
|
return e164
|
|
}
|
|
|
|
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) -> Promise<LookupResult> {
|
|
let validE164ToLookUp: String
|
|
switch searchResult {
|
|
case .valid(validE164: let validE164):
|
|
validE164ToLookUp = validE164
|
|
case .maybeValid(maybeValidE164: let maybeValidE164):
|
|
guard
|
|
let phoneNumber = PhoneNumber.tryParsePhoneNumber(fromUserSpecifiedText: maybeValidE164),
|
|
let validE164 = validE164(from: phoneNumber)
|
|
else {
|
|
return .value(.notValid(invalidE164: maybeValidE164))
|
|
}
|
|
validE164ToLookUp = validE164
|
|
}
|
|
return firstly {
|
|
contactDiscoveryManager.lookUp(phoneNumbers: [validE164ToLookUp], mode: .oneOffUserRequest)
|
|
}.map { signalRecipients in
|
|
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
|
|
}
|
|
|
|
@objc(findByNumberSectionForSearchResults:skippingPhoneNumbers:)
|
|
public func findByNumberSection(
|
|
for searchResults: ComposeScreenSearchResultSet,
|
|
skipping alreadyMatchedPhoneNumbers: Set<String>
|
|
) -> OWSTableSection? {
|
|
let phoneNumberFinder = PhoneNumberFinder(
|
|
localNumber: TSAccountManager.localNumber,
|
|
contactDiscoveryManager: contactDiscoveryManager
|
|
)
|
|
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, canCancel: true) { modal in
|
|
firstly {
|
|
finder.lookUp(phoneNumber: phoneNumberResult)
|
|
}.done(on: .main) { [weak self] lookupResult in
|
|
modal.dismissIfNotCanceled {
|
|
guard let self = self else { return }
|
|
self.handlePhoneNumberLookupResult(lookupResult)
|
|
}
|
|
}.catch(on: .main) { error in
|
|
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.
|
|
presentSMSInvitationSheet(for: validE164)
|
|
|
|
case (_, .notValid(invalidE164: let invalidE164)):
|
|
// If the number isn't valid, show an error so the user can fix it.
|
|
presentInvalidNumberSheet(for: invalidE164)
|
|
}
|
|
}
|
|
|
|
private func presentSMSInvitationSheet(for phoneNumber: String) {
|
|
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(
|
|
format: OWSLocalizedString(
|
|
"RECIPIENT_PICKER_INVITE_MESSAGE",
|
|
comment: "Alert text. Shown after selecting a phone number that isn't a Signal user."
|
|
),
|
|
PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: 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 self] action in
|
|
guard let self = self else { return }
|
|
guard MFMessageComposeViewController.canSendText() else {
|
|
OWSActionSheets.showErrorAlert(message: InviteFlow.unsupportedFeatureMessage)
|
|
return
|
|
}
|
|
let inviteFlow = InviteFlow(presentingViewController: self)
|
|
inviteFlow.sendSMSTo(phoneNumbers: [phoneNumber])
|
|
// We need to keep InviteFlow around until it's completed. We tie its
|
|
// lifetime to this view controller -- while not perfect, this avoids
|
|
// leaking the object.
|
|
self.inviteFlow = inviteFlow
|
|
}
|
|
))
|
|
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(
|
|
format: OWSLocalizedString(
|
|
"RECIPIENT_PICKER_INVALID_NUMBER_MESSAGE",
|
|
comment: "Alert text. Shown after selecting a phone number that isn't valid."
|
|
),
|
|
PhoneNumber.bestEffortFormatPartialUserSpecifiedText(toLookLikeAPhoneNumber: 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))
|
|
}
|
|
}
|
|
|
|
// MARK: - Find by Username
|
|
|
|
extension RecipientPickerViewController {
|
|
|
|
private func parsePossibleSearchUsername(for searchText: String) -> String? {
|
|
let username = 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
|
|
}
|
|
guard username != profileManager.localUsername() else {
|
|
// Don't show username results -- the user can't search for their own username
|
|
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
|
|
}
|
|
|
|
@objc(findByUsernameSectionForSearchResults:)
|
|
public func findByUsernameSection(for searchResults: ComposeScreenSearchResultSet) -> OWSTableSection? {
|
|
guard FeatureFlags.usernames else {
|
|
return nil
|
|
}
|
|
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) {
|
|
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true) { modal in
|
|
self.profileManagerImpl.fetchProfile(
|
|
forUsername: username,
|
|
success: { [weak self] address in
|
|
modal.dismissIfNotCanceled {
|
|
guard let self = self else { return }
|
|
self.tryToSelectRecipient(.for(address: address))
|
|
}
|
|
},
|
|
notFound: {
|
|
modal.dismissIfNotCanceled {
|
|
OWSActionSheets.showActionSheet(
|
|
title: OWSLocalizedString(
|
|
"USERNAME_NOT_FOUND_TITLE",
|
|
comment: "A message indicating that the given username was not registered with signal."
|
|
),
|
|
message: String(
|
|
format: OWSLocalizedString(
|
|
"USERNAME_NOT_FOUND_FORMAT",
|
|
comment: "A message indicating that the given username is not a registered signal account. Embeds {{username}}"
|
|
),
|
|
CommonFormats.formatUsername(username) ?? ""
|
|
)
|
|
)
|
|
}
|
|
},
|
|
failure: { error in
|
|
modal.dismissIfNotCanceled {
|
|
OWSActionSheets.showErrorAlert(
|
|
message: OWSLocalizedString(
|
|
"USERNAME_LOOKUP_ERROR",
|
|
comment: "A message indicating that username lookup failed."
|
|
)
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|