2024-09-03 15:13:58 +00:00
|
|
|
import Combine
|
|
|
|
import Foundation
|
|
|
|
import GRDB
|
|
|
|
|
2024-10-15 17:47:51 +00:00
|
|
|
enum ClientsListState {
|
|
|
|
case empty
|
|
|
|
case allDisabled
|
|
|
|
case haveSomeEnabled
|
|
|
|
}
|
|
|
|
|
2024-09-03 15:13:58 +00:00
|
|
|
@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] = []
|
2024-10-15 17:47:51 +00:00
|
|
|
@Published private(set) var listState: ClientsListState = .empty
|
2024-09-03 15:13:58 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-10-15 17:47:51 +00:00
|
|
|
if credentials.isEmpty {
|
|
|
|
listState = .empty
|
|
|
|
} else if credentials.allSatisfy({ !$0.isActive }) {
|
|
|
|
listState = .allDisabled
|
|
|
|
} else {
|
|
|
|
listState = .haveSomeEnabled
|
|
|
|
}
|
|
|
|
|
2024-10-20 19:28:59 +00:00
|
|
|
resubscribeRosters()
|
|
|
|
resubscribeChats()
|
|
|
|
reconnectNeeded()
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2024-10-19 15:17:14 +00:00
|
|
|
if let client = clients.first(where: { $0.credentials.bareJid == jidStr }) {
|
|
|
|
// check if credentials already exist and enable it
|
|
|
|
// this change will invoke reconnect automatically
|
|
|
|
try? await client.credentials.setActive(flag: 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
|
2024-09-03 15:13:58 +00:00
|
|
|
|
2024-10-19 15:17:14 +00:00
|
|
|
clients.append(client)
|
|
|
|
try? await client.credentials.save()
|
|
|
|
}
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
|
2024-10-15 17:47:51 +00:00
|
|
|
private func reconnectNeeded() {
|
2024-09-03 15:13:58 +00:00
|
|
|
Task {
|
|
|
|
await withTaskGroup(of: Void.self) { taskGroup in
|
|
|
|
for client in clients {
|
2024-10-15 17:47:51 +00:00
|
|
|
if !client.credentials.isActive && client.state == .enabled(.connected) {
|
2024-10-15 10:17:26 +00:00
|
|
|
taskGroup.addTask {
|
2024-10-15 17:47:51 +00:00
|
|
|
client.disconnect()
|
2024-10-15 10:17:26 +00:00
|
|
|
}
|
|
|
|
}
|
2024-10-15 17:47:51 +00:00
|
|
|
if client.credentials.isActive && client.state != .enabled(.connected) {
|
2024-10-15 10:17:26 +00:00
|
|
|
taskGroup.addTask {
|
2024-10-15 17:47:51 +00:00
|
|
|
await client.connect()
|
2024-10-15 10:17:26 +00:00
|
|
|
}
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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: [])
|
2024-09-16 12:10:35 +00:00
|
|
|
// 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: [])
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Produce stores for conversation
|
|
|
|
extension ClientsStore {
|
2024-09-20 15:32:10 +00:00
|
|
|
// swiftlint:disable:next large_tuple
|
2024-10-15 11:39:23 +00:00
|
|
|
func conversationStores(for roster: Roster) async throws -> (MessagesStore, AttachmentsStore, ChatSettingsStore) {
|
2024-09-03 15:13:58 +00:00
|
|
|
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)
|
2024-10-15 11:39:23 +00:00
|
|
|
let settingsStore = ChatSettingsStore(roster: roster, client: client)
|
2024-09-20 15:32:10 +00:00
|
|
|
return (conversationStore, attachmentsStore, settingsStore)
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
|
2024-09-20 15:32:10 +00:00
|
|
|
// swiftlint:disable:next large_tuple
|
2024-10-15 11:39:23 +00:00
|
|
|
func conversationStores(for chat: Chat) async throws -> (MessagesStore, AttachmentsStore, ChatSettingsStore) {
|
2024-09-03 15:13:58 +00:00
|
|
|
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)
|
2024-10-15 11:39:23 +00:00
|
|
|
let settingsStore = ChatSettingsStore(roster: roster, client: client)
|
2024-09-20 15:32:10 +00:00
|
|
|
return (conversationStore, attachmentsStore, settingsStore)
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Subscriptions
|
|
|
|
private extension ClientsStore {
|
|
|
|
private func resubscribeRosters() {
|
|
|
|
let clientsJids = clients
|
2024-10-20 19:28:59 +00:00
|
|
|
.filter { $0.credentials.isActive }
|
2024-09-03 15:13:58 +00:00
|
|
|
.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
|
2024-10-20 18:45:53 +00:00
|
|
|
.sorted {
|
|
|
|
if $0.bareJid != $1.bareJid {
|
|
|
|
return $0.bareJid < $1.bareJid
|
|
|
|
} else {
|
|
|
|
return $0.contactBareJid < $1.contactBareJid
|
|
|
|
}
|
|
|
|
}
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func resubscribeChats() {
|
|
|
|
let clientsJids = clients
|
2024-10-20 19:28:59 +00:00
|
|
|
.filter { $0.credentials.isActive }
|
2024-09-03 15:13:58 +00:00
|
|
|
.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
|
2024-10-21 08:23:13 +00:00
|
|
|
.sorted {
|
|
|
|
if $0.account != $1.account {
|
|
|
|
return $0.account < $1.account
|
|
|
|
} else {
|
|
|
|
return $0.participant < $1.participant
|
|
|
|
}
|
|
|
|
}
|
2024-09-03 15:13:58 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-10-04 15:58:04 +00:00
|
|
|
|
2024-10-22 17:57:50 +00:00
|
|
|
extension ClientsStore {
|
|
|
|
func reconnectOnActiveState() {
|
|
|
|
reconnectNeeded()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-10-04 15:58:04 +00:00
|
|
|
// MARK: - Remove all data for debug
|
|
|
|
#if DEBUG
|
|
|
|
extension ClientsStore {
|
|
|
|
func flushAllData() {
|
|
|
|
clients.forEach { $0.disconnect() }
|
|
|
|
clients.removeAll()
|
|
|
|
actualRosters.removeAll()
|
|
|
|
actualChats.removeAll()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|