Skip to content
Snippets Groups Projects
SearchLeftViewModel.swift 11.3 KiB
Newer Older
Bruno Muniz's avatar
Bruno Muniz committed
import Retry
import UIKit
Bruno Muniz's avatar
Bruno Muniz committed
import Shared
import Combine
import XXModels
import XXClient
import CustomDump
import ReportingFeature
import CombineSchedulers
Bruno Muniz's avatar
Bruno Muniz committed
import XXMessengerClient
import CountryListFeature
Bruno Muniz's avatar
Bruno Muniz committed
import DI

typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>

struct SearchLeftViewState {
Bruno Muniz's avatar
Bruno Muniz committed
  var input = ""
  var snapshot: SearchSnapshot?
  var country: Country = .fromMyPhone()
  var item: SearchSegmentedControl.Item = .username
final class SearchLeftViewModel {
Bruno Muniz's avatar
Bruno Muniz committed
  @Dependency var database: Database
  @Dependency var messenger: Messenger
  @Dependency var hudController: HUDController
  @Dependency var reportingStatus: ReportingStatus
  @Dependency var toastController: ToastController
  @Dependency var networkMonitor: NetworkMonitoring

  @KeyObject(.username, defaultValue: nil) var username: String?
  @KeyObject(.sharingEmail, defaultValue: false) var sharingEmail: Bool
  @KeyObject(.sharingPhone, defaultValue: false) var sharingPhone: Bool

  var myId: Data {
    try! messenger.e2e.get()!.getContact().getId()
  }

  var successPublisher: AnyPublisher<XXModels.Contact, Never> {
    successSubject.eraseToAnyPublisher()
  }

  var statePublisher: AnyPublisher<SearchLeftViewState, Never> {
    stateSubject.eraseToAnyPublisher()
  }

  var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler()

  var invitation: String?
  private var searchCancellables = Set<AnyCancellable>()
  private let successSubject = PassthroughSubject<XXModels.Contact, Never>()
  private let stateSubject = CurrentValueSubject<SearchLeftViewState, Never>(.init())
  private var networkCancellable = Set<AnyCancellable>()

  init(_ invitation: String? = nil) {
    self.invitation = invitation
  }

  func viewDidAppear() {
    if let pendingInvitation = invitation {
      invitation = nil
      stateSubject.value.input = pendingInvitation
Bruno Muniz's avatar
Bruno Muniz committed
      hudController.show(.init(
        actionTitle: Localized.Ud.Search.cancel,
        hasDotAnimation: true,
        onTapClosure: { [weak self] in
          guard let self else { return }
          self.didTapCancelSearch()
        }
      ))
Bruno Muniz's avatar
Bruno Muniz committed

      networkCancellable.removeAll()

Bruno Muniz's avatar
Bruno Muniz committed
      networkMonitor
        .statusPublisher
Bruno Muniz's avatar
Bruno Muniz committed
        .first { $0 == .available }
        .eraseToAnyPublisher()
        .flatMap { _ in
          self.waitForNodes(timeout: 5)
        }.sink(receiveCompletion: {
          if case .failure(let error) = $0 {
            self.hudController.show(.init(error: error))
          }
        }, receiveValue: {
          self.didStartSearching()
        }).store(in: &networkCancellable)
Bruno Muniz's avatar
Bruno Muniz committed
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didEnterInput(_ string: String) {
    stateSubject.value.input = string
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didPick(country: Country) {
    stateSubject.value.country = country
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didSelectItem(_ item: SearchSegmentedControl.Item) {
    stateSubject.value.item = item
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didTapCancelSearch() {
    searchCancellables.forEach { $0.cancel() }
    searchCancellables.removeAll()
    hudController.dismiss()
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didStartSearching() {
    guard stateSubject.value.input.isEmpty == false else { return }
Bruno Muniz's avatar
Bruno Muniz committed
    hudController.show(.init(
      actionTitle: Localized.Ud.Search.cancel,
      hasDotAnimation: true,
      onTapClosure: { [weak self] in
        guard let self else { return }
        self.didTapCancelSearch()
      }
    ))
Bruno Muniz's avatar
Bruno Muniz committed
    var content = stateSubject.value.input
Bruno Muniz's avatar
Bruno Muniz committed
    if stateSubject.value.item == .phone {
      content += stateSubject.value.country.code
Bruno Muniz's avatar
Bruno Muniz committed
    enum NodeRegistrationError: Error {
      case unhealthyNet
      case belowMinimum
Bruno Muniz's avatar
Bruno Muniz committed
    retry(max: 5, retryStrategy: .delay(seconds: 2)) { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
      guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed

      do {
        let nrr = try self.messenger.cMix.get()!.getNodeRegistrationStatus()
        if nrr.ratio < 0.8 { throw NodeRegistrationError.belowMinimum }
      } catch {
        throw NodeRegistrationError.unhealthyNet
      }
    }.finalCatch { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
      guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed

      if case .unhealthyNet = $0 as? NodeRegistrationError {
        self.hudController.show(.init(content: "Network is not healthy yet, try again within the next minute or so."))
      } else if case .belowMinimum = $0 as? NodeRegistrationError {
        self.hudController.show(.init(content:"Node registration ratio is still below 80%, try again within the next minute or so."))
      } else {
        self.hudController.show(.init(error: $0))
      }

      return
Bruno Muniz's avatar
Bruno Muniz committed
    var factType: FactType = .username
Bruno Muniz's avatar
Bruno Muniz committed
    if stateSubject.value.item == .phone {
      factType = .phone
    } else if stateSubject.value.item == .email {
      factType = .email
    }
Bruno Muniz's avatar
Bruno Muniz committed
    backgroundScheduler.schedule { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
      guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed

      do {
        let report = try SearchUD.live(
          params: .init(
            e2eId: self.messenger.e2e.get()!.getId(),
            udContact: self.messenger.ud.get()!.getContact(),
            facts: [.init(type: factType, value: content)]
          ),
          callback: .init(handle: {
            switch $0 {
            case .success(let results):
              self.hudController.dismiss()
              self.appendToLocalSearch(
                XXModels.Contact(
                  id: try! results.first!.getId(),
                  marshaled: results.first!.data,
                  username: try! results.first?.getFacts().first(where: { $0.type == .username })?.value,
                  email: try? results.first?.getFacts().first(where: { $0.type == .email })?.value,
                  phone: try? results.first?.getFacts().first(where: { $0.type == .phone })?.value,
                  nickname: nil,
                  photo: nil,
                  authStatus: .stranger,
                  isRecent: true,
                  isBlocked: false,
                  isBanned: false,
                  createdAt: Date()
Bruno Muniz's avatar
Bruno Muniz committed
              )
            case .failure(let error):
              print(">>> SearchUD error: \(error.localizedDescription)")
Bruno Muniz's avatar
Bruno Muniz committed
              self.appendToLocalSearch(nil)
              self.hudController.show(.init(error: error))
Bruno Muniz's avatar
Bruno Muniz committed
          })
        )
Bruno Muniz's avatar
Bruno Muniz committed
        print(">>> UDSearch.Report: \(report))")
      } catch {
        print(">>> UDSearch.Exception: \(error.localizedDescription)")
      }
    }
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didTapResend(contact: XXModels.Contact) {
    hudController.show()
Bruno Muniz's avatar
Bruno Muniz committed
    var contact = contact
    contact.authStatus = .requesting
Bruno Muniz's avatar
Bruno Muniz committed
    backgroundScheduler.schedule { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
      guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed
      do {
        try self.database.saveContact(contact)
Bruno Muniz's avatar
Bruno Muniz committed
        var includedFacts: [Fact] = []
        let myFacts = try self.messenger.ud.get()!.getFacts()
Bruno Muniz's avatar
Bruno Muniz committed
        if let fact = myFacts.get(.username) {
          includedFacts.append(fact)
Bruno Muniz's avatar
Bruno Muniz committed
        }

Bruno Muniz's avatar
Bruno Muniz committed
        if self.sharingEmail, let fact = myFacts.get(.email) {
          includedFacts.append(fact)
        }
Bruno Muniz's avatar
Bruno Muniz committed
        if self.sharingPhone, let fact = myFacts.get(.phone) {
          includedFacts.append(fact)
        }
Bruno Muniz's avatar
Bruno Muniz committed
        let _ = try self.messenger.e2e.get()!.requestAuthenticatedChannel(
          partner: .live(contact.marshaled!),
          myFacts: includedFacts
        )
Bruno Muniz's avatar
Bruno Muniz committed
        contact.authStatus = .requested
        contact = try self.database.saveContact(contact)
Bruno Muniz's avatar
Bruno Muniz committed
        self.hudController.dismiss()
        self.presentSuccessToast(for: contact, resent: true)
      } catch {
        contact.authStatus = .requestFailed
        _ = try? self.database.saveContact(contact)
        self.hudController.show(.init(error: error))
      }
    }
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didTapRequest(contact: XXModels.Contact) {
    hudController.show()
Bruno Muniz's avatar
Bruno Muniz committed
    var contact = contact
    contact.nickname = contact.username
    contact.authStatus = .requesting
Bruno Muniz's avatar
Bruno Muniz committed
    backgroundScheduler.schedule { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
      guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed
      do {
        try self.database.saveContact(contact)
Bruno Muniz's avatar
Bruno Muniz committed
        var includedFacts: [Fact] = []
        let myFacts = try self.messenger.ud.get()!.getFacts()
Bruno Muniz's avatar
Bruno Muniz committed
        if let fact = myFacts.get(.username) {
          includedFacts.append(fact)
Bruno Muniz's avatar
Bruno Muniz committed
        if self.sharingEmail, let fact = myFacts.get(.email) {
          includedFacts.append(fact)
Bruno Muniz's avatar
Bruno Muniz committed
        if self.sharingPhone, let fact = myFacts.get(.phone) {
          includedFacts.append(fact)
Bruno Muniz's avatar
Bruno Muniz committed
        let _ = try self.messenger.e2e.get()!.requestAuthenticatedChannel(
          partner: .live(contact.marshaled!),
          myFacts: includedFacts
Bruno Muniz's avatar
Bruno Muniz committed
        )
Bruno Muniz's avatar
Bruno Muniz committed
        contact.authStatus = .requested
        contact = try self.database.saveContact(contact)

        self.hudController.dismiss()
        self.successSubject.send(contact)
        self.presentSuccessToast(for: contact, resent: false)
      } catch {
        contact.authStatus = .requestFailed
        _ = try? self.database.saveContact(contact)
        self.hudController.show(.init(error: error))
      }
Bruno Muniz's avatar
Bruno Muniz committed
  }
Bruno Muniz's avatar
Bruno Muniz committed
  func didSet(nickname: String, for contact: XXModels.Contact) {
    if var contact = try? database.fetchContacts(.init(id: [contact.id])).first {
      contact.nickname = nickname
      _ = try? database.saveContact(contact)
Bruno Muniz's avatar
Bruno Muniz committed
  }

  private func appendToLocalSearch(_ user: XXModels.Contact?) {
    var snapshot = SearchSnapshot()

    if var user = user {
      if let contact = try? database.fetchContacts(.init(id: [user.id])).first {
        user.isBanned = contact.isBanned
        user.isBlocked = contact.isBlocked
        user.authStatus = contact.authStatus
      }

      if user.authStatus != .friend, !reportingStatus.isEnabled() {
        snapshot.appendSections([.stranger])
        snapshot.appendItems([.stranger(user)], toSection: .stranger)
      } else if user.authStatus != .friend, reportingStatus.isEnabled(), !user.isBanned, !user.isBlocked {
        snapshot.appendSections([.stranger])
        snapshot.appendItems([.stranger(user)], toSection: .stranger)
      }
Bruno Muniz's avatar
Bruno Muniz committed
    }
Bruno Muniz's avatar
Bruno Muniz committed
    let localsQuery = Contact.Query(
      text: stateSubject.value.input,
      authStatus: [.friend],
      isBlocked: reportingStatus.isEnabled() ? false : nil,
      isBanned: reportingStatus.isEnabled() ? false : nil
    )

    if let locals = try? database.fetchContacts(localsQuery),
       let localsWithoutMe = removeMyself(from: locals),
       localsWithoutMe.isEmpty == false {
      snapshot.appendSections([.connections])
      snapshot.appendItems(
        localsWithoutMe.map(SearchItem.connection),
        toSection: .connections
      )
Bruno Muniz's avatar
Bruno Muniz committed

    stateSubject.value.snapshot = snapshot
  }

  private func removeMyself(from collection: [XXModels.Contact]) -> [XXModels.Contact]? {
    collection.filter { $0.id != myId }
  }

  private func presentSuccessToast(for contact: XXModels.Contact, resent: Bool) {
    let name = contact.nickname ?? contact.username
    let sentTitle = Localized.Requests.Sent.Toast.sent(name ?? "")
    let resentTitle = Localized.Requests.Sent.Toast.resent(name ?? "")

    toastController.enqueueToast(model: .init(
      title: resent ? resentTitle : sentTitle,
      leftImage: Asset.sharedSuccess.image
    ))
  }

  private func waitForNodes(timeout: Int) -> AnyPublisher<Void, Error> {
    Deferred {
      Future { promise in
        retry(max: timeout, retryStrategy: .delay(seconds: 1)) { [weak self] in
Bruno Muniz's avatar
Bruno Muniz committed
          guard let self else { return }
Bruno Muniz's avatar
Bruno Muniz committed
          _ = try self.messenger.cMix.get()!.getNodeRegistrationStatus()
          promise(.success(()))
        }.finalCatch {
          promise(.failure($0))
        }
      }
    }.eraseToAnyPublisher()
  }