Skip to content
Snippets Groups Projects
SearchLeftViewModel.swift 13 KiB
Newer Older
import HUD
Bruno Muniz's avatar
Bruno Muniz committed
import Retry
import UIKit
Bruno Muniz's avatar
Bruno Muniz committed
import Models
Bruno Muniz's avatar
Bruno Muniz committed
import Shared
import Combine
import XXModels
import XXClient
import Countries
import CustomDump
import ReportingFeature
import CombineSchedulers
Bruno Muniz's avatar
Bruno Muniz committed
import XXMessengerClient
Bruno Muniz's avatar
Bruno Muniz committed
import DependencyInjection

typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>

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

    @KeyObject(.username, defaultValue: nil) var username: String?
Bruno Muniz's avatar
Bruno Muniz committed
    @KeyObject(.sharingEmail, defaultValue: false) var sharingEmail: Bool
    @KeyObject(.sharingPhone, defaultValue: false) var sharingPhone: Bool

    var myId: Data {
Bruno Muniz's avatar
Bruno Muniz committed
        try! messenger.e2e.get()!.getContact().getId()
    var hudPublisher: AnyPublisher<HUDStatus, Never> {
        hudSubject.eraseToAnyPublisher()
    }

Bruno Muniz's avatar
Bruno Muniz committed
    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>()
Bruno Muniz's avatar
Bruno Muniz committed
    private let successSubject = PassthroughSubject<XXModels.Contact, Never>()
    private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none)
    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
            hudSubject.send(.onAction(Localized.Ud.Search.cancel))

Bruno Muniz's avatar
Bruno Muniz committed
            networkCancellable.removeAll()

            networkMonitor.statusPublisher
                .first { $0 == .available }
                .eraseToAnyPublisher()
                .flatMap { _ in
                    self.waitForNodes(timeout: 5)
                }.sink(receiveCompletion: {
                    if case .failure(let error) = $0 {
                        self.hudSubject.send(.error(.init(with: error)))
                    }
                }, receiveValue: {
                    self.didStartSearching()
                }).store(in: &networkCancellable)

    func didEnterInput(_ string: String) {
        stateSubject.value.input = string
    }

    func didPick(country: Country) {
        stateSubject.value.country = country
    }

    func didSelectItem(_ item: SearchSegmentedControl.Item) {
        stateSubject.value.item = item
    }

    func didTapCancelSearch() {
        searchCancellables.forEach { $0.cancel() }
        searchCancellables.removeAll()
        hudSubject.send(.none)
    }

    func didStartSearching() {
Bruno Muniz's avatar
Bruno Muniz committed
        guard stateSubject.value.input.isEmpty == false else { return }

Bruno Muniz's avatar
Bruno Muniz committed
        hudSubject.send(.onAction(Localized.Ud.Search.cancel))
        var content = stateSubject.value.input
        if stateSubject.value.item == .phone {
            content += stateSubject.value.country.code
        }

Bruno Muniz's avatar
Bruno Muniz committed
        enum NodeRegistrationError: Error {
            case unhealthyNet
            case belowMinimum
        }

        retry(max: 5, retryStrategy: .delay(seconds: 2)) { [weak self] in
            guard let self = self else { return }

            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
            guard let self = self else { return }

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

            return
        }
Bruno Muniz's avatar
Bruno Muniz committed
        var factType: FactType = .username

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

            do {
                let report = try SearchUD.live(
Bruno Muniz's avatar
Bruno Muniz committed
                    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 {
Bruno Muniz's avatar
Bruno Muniz committed
                        case .success(let results):
Bruno Muniz's avatar
Bruno Muniz committed
                            self.hudSubject.send(.none)
                            self.appendToLocalSearch(
Bruno Muniz's avatar
Bruno Muniz committed
                                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,
Bruno Muniz's avatar
Bruno Muniz committed
                                    nickname: nil,
                                    photo: nil,
                                    authStatus: .stranger,
Bruno Muniz's avatar
Bruno Muniz committed
                                    isRecent: true,
Bruno Muniz's avatar
Bruno Muniz committed
                                    isBlocked: false,
                                    isBanned: false,
                                    createdAt: Date()
                                )
Bruno Muniz's avatar
Bruno Muniz committed
                            )
                        case .failure(let error):
Bruno Muniz's avatar
Bruno Muniz committed
                            print(">>> SearchUD error: \(error.localizedDescription)")

                            self.appendToLocalSearch(nil)
                            self.hudSubject.send(.error(.init(with: error)))
                        }
                    })
                )

Bruno Muniz's avatar
Bruno Muniz committed
                print(">>> UDSearch.Report: \(report))")
Bruno Muniz's avatar
Bruno Muniz committed
                print(">>> UDSearch.Exception: \(error.localizedDescription)")
Bruno Muniz's avatar
Bruno Muniz committed
        }
Bruno Muniz's avatar
Bruno Muniz committed
    func didTapResend(contact: XXModels.Contact) {
Bruno Muniz's avatar
Bruno Muniz committed
        hudSubject.send(.on)

        var contact = contact
        contact.authStatus = .requesting

        backgroundScheduler.schedule { [weak self] in
            guard let self = self else { return }

            do {
                try self.database.saveContact(contact)

Bruno Muniz's avatar
Bruno Muniz committed
                var includedFacts: [Fact] = []
                let myFacts = try self.messenger.ud.get()!.getFacts()

                if let fact = myFacts.get(.username) {
                    includedFacts.append(fact)
                }

                if self.sharingEmail, let fact = myFacts.get(.email) {
                    includedFacts.append(fact)
                }

                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(
Bruno Muniz's avatar
Bruno Muniz committed
                    partner: .live(contact.marshaled!),
Bruno Muniz's avatar
Bruno Muniz committed
                    myFacts: includedFacts
                )

                contact.authStatus = .requested
                contact = try self.database.saveContact(contact)

                self.hudSubject.send(.none)
Bruno Muniz's avatar
Bruno Muniz committed
                self.presentSuccessToast(for: contact, resent: true)
            } catch {
                contact.authStatus = .requestFailed
                _ = try? self.database.saveContact(contact)
                self.hudSubject.send(.error(.init(with: error)))
            }
Bruno Muniz's avatar
Bruno Muniz committed
    func didTapRequest(contact: XXModels.Contact) {
Bruno Muniz's avatar
Bruno Muniz committed
        hudSubject.send(.on)
        var contact = contact
        contact.nickname = contact.username
        contact.authStatus = .requesting

        backgroundScheduler.schedule { [weak self] in
            guard let self = self else { return }
            do {
                try self.database.saveContact(contact)

Bruno Muniz's avatar
Bruno Muniz committed
                var includedFacts: [Fact] = []
                let myFacts = try self.messenger.ud.get()!.getFacts()

                if let fact = myFacts.get(.username) {
                    includedFacts.append(fact)
                }

                if self.sharingEmail, let fact = myFacts.get(.email) {
                    includedFacts.append(fact)
                }

                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(
Bruno Muniz's avatar
Bruno Muniz committed
                    partner: .live(contact.marshaled!),
Bruno Muniz's avatar
Bruno Muniz committed
                    myFacts: includedFacts
                )

                contact.authStatus = .requested
                contact = try self.database.saveContact(contact)

                self.hudSubject.send(.none)
                self.successSubject.send(contact)
Bruno Muniz's avatar
Bruno Muniz committed
                self.presentSuccessToast(for: contact, resent: false)
            } catch {
                contact.authStatus = .requestFailed
                _ = try? self.database.saveContact(contact)
                self.hudSubject.send(.error(.init(with: error)))
            }
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
        let localsQuery = Contact.Query(
            text: stateSubject.value.input,
            authStatus: [.friend],
            isBlocked: reportingStatus.isEnabled() ? false : nil,
            isBanned: reportingStatus.isEnabled() ? false : nil
Bruno Muniz's avatar
Bruno Muniz committed
        )
        if let locals = try? database.fetchContacts(localsQuery),
Bruno Muniz's avatar
Bruno Muniz committed
           let localsWithoutMe = removeMyself(from: locals),
           localsWithoutMe.isEmpty == false {
            snapshot.appendSections([.connections])
Bruno Muniz's avatar
Bruno Muniz committed
            snapshot.appendItems(
                localsWithoutMe.map(SearchItem.connection),
                toSection: .connections
            )
        }

        stateSubject.value.snapshot = snapshot
    }
Bruno Muniz's avatar
Bruno Muniz committed
    private func removeMyself(from collection: [XXModels.Contact]) -> [XXModels.Contact]? {
        collection.filter { $0.id != myId }
Bruno Muniz's avatar
Bruno Muniz committed

    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
                    guard let self = self else { return }
                    _ = try self.messenger.cMix.get()!.getNodeRegistrationStatus()
                    promise(.success(()))
                }.finalCatch {
                    promise(.failure($0))
                }
            }
        }.eraseToAnyPublisher()
    }