Signal-iOS/SignalUI/ViewControllers/Recipient Picker/RecipientPickerViewController.swift
Harry a146e18aaf
Set up account state and exit registration
* Go to chat list view after registration

* sync system contacts during registration

* Use explicit local credentials for storage service operations during registration

* fix tests

* Quick hack to get through double pin confirmation

* Finishing touches

* lint

* fix build

* reload phone number discoverability after storage service sync

* fix tests again

* Take chat auth on account and contact record initializers

* Change around branches for clarity in OWSUserProfile

* pr comments

* Split ChatServiceAuth into the same and AuthedAccount

* fix tests

* merge woes
2023-03-09 21:54:51 -08:00

900 lines
37 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: DispatchQueue.main) { [weak self] _ in
modal.dismiss {
self?.didPrepareToSelectRecipient(recipient)
}
}.catch(on: DispatchQueue.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 .notAllowed where shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction():
// 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, .notAllowed:
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, .notAllowed:
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
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."
),
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
})
}
private static let keyValueStore = SDSKeyValueStore(collection: "RecipientPicker.contactAccess")
private static let showNotAllowedReminderKey = "shouldShowNotAllowedReminder"
private func shouldShowContactAccessNotAllowedReminderItemWithSneakyTransaction() -> Bool {
databaseStorage.read {
Self.keyValueStore.getBool(Self.showNotAllowedReminderKey, defaultValue: true, transaction: $0)
}
}
private func hideShowContactAccessNotAllowedReminderItem() {
databaseStorage.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()
}
)
})
}
}
// 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: DispatchQueue.main) { [weak self] lookupResult in
modal.dismissIfNotCanceled {
guard let self = self else { return }
self.handlePhoneNumberLookupResult(lookupResult)
}
}.catch(on: DispatchQueue.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
}
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) {
guard
let localAddress = tsAccountManager.localAddress,
let localAci = localAddress.uuid
else {
owsFailDebug("Missing local UUID!")
return
}
if
let localUsername = databaseStorage.read(block: { transaction -> String? in
DependenciesBridge.shared.usernameLookupManager.fetchUsername(
forAci: localAci,
transaction: transaction.asV2Read
)
}),
localUsername.caseInsensitiveCompare(username) == .orderedSame {
// Searched for ourselves, no reason to hit the service.
tryToSelectRecipient(.for(address: localAddress))
return
}
if let hashedUsername = try? Usernames.HashedUsername(forUsername: username) {
performUsernameLookup(forHashedUsername: hashedUsername)
} else {
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
"USERNAME_LOOKUP_INVALID_USERNAME_TITLE",
comment: "Title for an action sheet indicating that a user-entered username value is not a valid username."
),
message: String(
format: OWSLocalizedString(
"USERNAME_LOOKUP_INVALID_USERNAME_MESSAGE_FORMAT",
comment: "A message indicating that a user-entered username value is not a valid username. Embeds {{ a username }}."
),
username
)
)
}
}
private func performUsernameLookup(
forHashedUsername hashedUsername: Usernames.HashedUsername
) {
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true) { modal in
firstly { () -> Promise<UUID?> in
Usernames.API(networkManager: self.networkManager)
.attemptAciLookup(forHashedUsername: hashedUsername)
}.done(on: DispatchQueue.main) { [weak self] maybeAci in
modal.dismissIfNotCanceled {
if let aci = maybeAci {
guard let self else { return }
self.handleUsernameLookupCompleted(
withAci: aci,
forUsername: hashedUsername.usernameString
)
} else {
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
"USERNAME_LOOKUP_NOT_FOUND_TITLE",
comment: "Title for an action sheet indicating that the given username is not associated with a registered Signal account."
),
message: String(
format: OWSLocalizedString(
"USERNAME_LOOKUP_NOT_FOUND_MESSAGE_FORMAT",
comment: "A message indicating that the given username is not associated with a registered Signal account. Embeds {{ a username }}."
),
hashedUsername.usernameString
)
)
}
}
}.catch(on: DispatchQueue.main) { error in
modal.dismissIfNotCanceled {
OWSActionSheets.showErrorAlert(message: OWSLocalizedString(
"USERNAME_LOOKUP_ERROR_MESSAGE",
comment: "A message indicating that username lookup failed."
))
}
}
}
}
private func handleUsernameLookupCompleted(
withAci aci: UUID,
forUsername username: String
) {
let addressForUsername = SignalServiceAddress(uuid: aci)
self.databaseStorage.write { transaction in
let recipient = SignalRecipient.fetchOrCreate(
for: addressForUsername,
trustLevel: .low,
transaction: transaction
)
recipient.markAsRegistered(transaction: transaction)
let isUsernameBestIdentifier = Usernames.BetterIdentifierChecker.assembleByQuerying(
forRecipient: recipient,
profileManager: self.profileManager,
contactManager: self.contactsManager,
transaction: transaction
).usernameIsBestIdentifier()
if isUsernameBestIdentifier {
// If this username is the best identifier we have for this
// address, we should save it locally and in StorageService.
DependenciesBridge.shared.usernameLookupManager.saveUsername(
username,
forAci: aci,
transaction: transaction.asV2Write
)
self.storageServiceManager.recordPendingUpdates(
updatedAddresses: [addressForUsername],
authedAccount: .implicit()
)
} else {
// If we have a better identifier for this address, we can
// throw away any stored username info for it.
DependenciesBridge.shared.usernameLookupManager.saveUsername(
nil,
forAci: aci,
transaction: transaction.asV2Write
)
}
}
self.tryToSelectRecipient(.for(address: addressForUsername))
}
}