import Combine import Foundation import GRDB enum ClientsListState { case empty case allDisabled case haveSomeEnabled } @MainActor final class ClientsStore: ObservableObject { static let shared = ClientsStore() @Published private(set) var ready = false @Published private(set) var clients: [Client] = [] @Published private(set) var actualRosters: [Roster] = [] @Published private(set) var actualChats: [Chat] = [] @Published private(set) var listState: ClientsListState = .empty private var credentialsCancellable: AnyCancellable? private var rostersCancellable: AnyCancellable? private var chatsCancellable: AnyCancellable? init() { credentialsCancellable = ValueObservation .tracking { db in try Credentials.fetchAll(db) } .publisher(in: Database.shared.dbQueue) .catch { _ in Just([]) } .sink { [weak self] creds in self?.processCredentials(creds) } } private func processCredentials(_ credentials: [Credentials]) { let existsJids = Set(clients.map { $0.credentials.bareJid }) let credentialsJids = Set(credentials.map { $0.bareJid }) let forAdd = credentials.filter { !existsJids.contains($0.bareJid) } let newClients = forAdd.map { Client(credentials: $0) } let forRemove = clients.filter { !credentialsJids.contains($0.credentials.bareJid) } forRemove.forEach { $0.disconnect() } var updatedClients = clients.filter { credentialsJids.contains($0.credentials.bareJid) } updatedClients.append(contentsOf: newClients) clients = updatedClients if !ready { ready = true } if credentials.isEmpty { listState = .empty } else if credentials.allSatisfy({ !$0.isActive }) { listState = .allDisabled } else { listState = .haveSomeEnabled } resubscribeRosters() resubscribeChats() reconnectNeeded() } private func client(for credentials: Credentials) -> Client? { clients.first { $0.credentials == credentials } } } // MARK: - Login/Connections extension ClientsStore { func tryLogin(_ jidStr: String, _ pass: String) async throws { if let client = clients.first(where: { $0.credentials.bareJid == jidStr }) { // check if credentials already exist and enable it // this change will invoke reconnect automatically await client.updActivity(true) } else { // new client login with fake timeout async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) async let request = try await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true)) let client = try await(request, sleep).0 try? await client.credentials.save() } } private func reconnectNeeded() { Task { await withTaskGroup(of: Void.self) { taskGroup in for client in clients { if !client.credentials.isActive && client.state == .enabled(.connected) { taskGroup.addTask { client.disconnect() } } if client.credentials.isActive && client.state != .enabled(.connected) { taskGroup.addTask { await client.connect() } } } } } } } // MARK: - Manage Rosters extension ClientsStore { func addRoster(_ credentials: Credentials, contactJID: String, name: String?, groups: [String]) async throws { // check that roster exist in db as locally deleted and undelete it let deletedLocally = await Roster.allDeletedLocally if var roster = deletedLocally.first(where: { $0.contactBareJid == contactJID }) { try await roster.setLocallyDeleted(false) return } // add new roster guard let client = client(for: credentials) else { throw AppError.clientNotFound } try await client.addRoster(contactJID, name: name, groups: groups) } func deleteRoster(_ roster: Roster) async throws { guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { throw AppError.clientNotFound } try await client.deleteRoster(roster) } } extension ClientsStore { func addRosterForNewChatIfNeeded(_ chat: Chat) async throws { let exists = try? await chat.fetchRoster() if exists == nil { guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { throw AppError.clientNotFound } try await addRoster(client.credentials, contactJID: chat.participant, name: nil, groups: []) // Hack here. Because we want to show chat immediately after adding roster (without waiting for server // response and update rosters list) we need to write it to db manually try await client.addRosterLocally(chat.participant, name: nil, groups: []) } } } // MARK: - Produce stores for conversation extension ClientsStore { // swiftlint:disable:next large_tuple func conversationStores(for roster: Roster) async throws -> (MessagesStore, AttachmentsStore, ChatSettingsStore) { while !ready { await Task.yield() } guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { throw AppError.clientNotFound } let conversationStore = MessagesStore(roster: roster, client: client) let attachmentsStore = AttachmentsStore(roster: roster, client: client) let settingsStore = ChatSettingsStore(roster: roster, client: client) return (conversationStore, attachmentsStore, settingsStore) } // swiftlint:disable:next large_tuple func conversationStores(for chat: Chat) async throws -> (MessagesStore, AttachmentsStore, ChatSettingsStore) { while !ready { await Task.yield() } guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { throw AppError.clientNotFound } let roster = try await chat.fetchRoster() let conversationStore = MessagesStore(roster: roster, client: client) let attachmentsStore = AttachmentsStore(roster: roster, client: client) let settingsStore = ChatSettingsStore(roster: roster, client: client) return (conversationStore, attachmentsStore, settingsStore) } } // MARK: - Subscriptions private extension ClientsStore { private func resubscribeRosters() { let clientsJids = clients .filter { $0.credentials.isActive } .map { $0.credentials.bareJid } rostersCancellable = ValueObservation.tracking { db in try Roster .filter(clientsJids.contains(Column("bareJid"))) .filter(Column("locallyDeleted") == false) .fetchAll(db) } .publisher(in: Database.shared.dbQueue) .catch { _ in Just([]) } .sink { [weak self] rosters in self?.actualRosters = rosters .sorted { if $0.bareJid != $1.bareJid { return $0.bareJid < $1.bareJid } else { return $0.contactBareJid < $1.contactBareJid } } } } func resubscribeChats() { let clientsJids = clients .filter { $0.credentials.isActive } .map { $0.credentials.bareJid } chatsCancellable = ValueObservation.tracking { db in try Chat .filter(clientsJids.contains(Column("account"))) .fetchAll(db) } .publisher(in: Database.shared.dbQueue) .catch { _ in Just([]) } .sink { [weak self] chats in self?.actualChats = chats .sorted { if $0.account != $1.account { return $0.account < $1.account } else { return $0.participant < $1.participant } } } } } extension ClientsStore { func reconnectOnActiveState() { reconnectNeeded() } } // MARK: - Remove all data for debug #if DEBUG extension ClientsStore { func flushAllData() { clients.forEach { $0.disconnect() } clients.removeAll() actualRosters.removeAll() actualChats.removeAll() } } #endif