// // 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 { 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 ) -> 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 in Usernames.API( networkManager: self.networkManager, schedulers: DependenciesBridge.shared.schedulers ) .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)) } }