import Combine import Foundation import GRDB final class DatabaseMiddleware { static let shared = DatabaseMiddleware() private let database = Database.shared private var cancellables: Set = [] private var conversationCancellables: Set = [] private init() { // Database changes ValueObservation .tracking(Roster.fetchAll) .publisher(in: database._db, scheduling: .immediate) .sink { _ in // Handle completion } receiveValue: { rosters in DispatchQueue.main.async { store.dispatch(.databaseAction(.storedRostersLoaded(rosters: rosters))) } } .store(in: &cancellables) ValueObservation .tracking(Chat.fetchAll) .publisher(in: database._db, scheduling: .immediate) .sink { _ in // Handle completion } receiveValue: { chats in DispatchQueue.main.async { store.dispatch(.databaseAction(.storedChatsLoaded(chats: chats))) } } .store(in: &cancellables) } // swiftlint:disable:next function_body_length func middleware(state _: AppState, action: AppAction) -> AnyPublisher { switch action { // MARK: Accounts case .startAction(.loadStoredAccounts): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.loadingStoredAccountsFailed))) return } do { try database._db.read { db in let accounts = try Account.fetchAll(db) promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts)))) } } catch { promise(.success(.databaseAction(.loadingStoredAccountsFailed))) } } } .eraseToAnyPublisher() case .accountsAction(.makeAccountPermanent(let account)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.updateAccountFailed))) return } do { try database._db.write { db in // make permanent and store to database var acc = account acc.isTemp = false try acc.insert(db) // Re-Fetch all accounts let accounts = try Account.fetchAll(db) // Use the accounts promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts)))) } } catch { promise(.success(.databaseAction(.updateAccountFailed))) } } } .eraseToAnyPublisher() // MARK: Rosters case .rostersAction(.markRosterAsLocallyDeleted(let ownerJID, let contactJID)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) return } do { _ = try database._db.write { db in try Roster .filter(Column("bareJid") == ownerJID) .filter(Column("contactBareJid") == contactJID) .updateAll(db, Column("locallyDeleted").set(to: true)) } promise(.success(.empty)) } catch { promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) } } } .eraseToAnyPublisher() case .rostersAction(.unmarkRosterAsLocallyDeleted(let ownerJID, let contactJID)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) return } do { _ = try database._db.write { db in try Roster .filter(Column("bareJid") == ownerJID) .filter(Column("contactBareJid") == contactJID) .updateAll(db, Column("locallyDeleted").set(to: false)) } promise(.success(.empty)) } catch { promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError)))) } } } .eraseToAnyPublisher() // MARK: Chats case .chatsAction(.createNewChat(let accountJid, let participantJid)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError)))) return } do { try database._db.write { db in let chat = Chat( id: UUID().uuidString, account: accountJid, participant: participantJid, type: .chat ) try chat.insert(db) promise(.success(.chatsAction(.chatCreated(chat: chat)))) } } catch { promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError)))) } } } .eraseToAnyPublisher() // MARK: Conversation and messages case .conversationAction(.makeConversationActive(let chat, _)): subscribeToMessages(chat: chat) return Empty().eraseToAnyPublisher() case .xmppAction(.xmppMessageReceived(let message)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) return } guard message.contentType != .typing, message.body != nil else { promise(.success(.empty)) return } do { try database._db.write { db in try message.insert(db) } if let remoteUrl = message.oobUrl { let attachment = Attachment( id: UUID().uuidString, type: remoteUrl.attachmentType, localPath: nil, remotePath: URL(string: remoteUrl), localThumbnailPath: nil, messageId: message.id ) try database._db.write { db in try attachment.insert(db) try Message .filter(Column("id") == message.id) .updateAll(db, [Column("attachmentId").set(to: attachment.id)]) } } promise(.success(.empty)) } catch { promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) } } } .eraseToAnyPublisher() case .conversationAction(.sendMessage(let from, let to, let body)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) return } do { let message = Message( id: UUID().uuidString, type: .chat, contentType: .text, from: from, to: to, body: body, subject: nil, thread: nil, oobUrl: nil, date: Date(), pending: true, sentError: false ) try database._db.write { db in try message.insert(db) } promise(.success(.xmppAction(.xmppMessageSent(message)))) } catch { promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) } } } .eraseToAnyPublisher() case .xmppAction(.xmppMessageSendSuccess(let msgId)): // mark message as pending false and sentError false return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) return } do { _ = try database._db.write { db in try Message .filter(Column("id") == msgId) .updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: false)) } promise(.success(.empty)) } catch { promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) ) } } } .eraseToAnyPublisher() case .xmppAction(.xmppMessageSendFailed(let msgId)): // mark message as pending false and sentError true return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) return } do { _ = try database._db.write { db in try Message .filter(Column("id") == msgId) .updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true)) } promise(.success(.empty)) } catch { promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) ) } } } .eraseToAnyPublisher() default: return Empty().eraseToAnyPublisher() } } } private extension DatabaseMiddleware { func subscribeToMessages(chat: Chat) { conversationCancellables = [] ValueObservation .tracking( Message .filter( (Column("to") == chat.account && Column("from") == chat.participant) || (Column("from") == chat.account && Column("to") == chat.participant) ) .order(Column("date").desc) .including(optional: Message.attachment) .fetchAll ) .publisher(in: database._db, scheduling: .immediate) .sink { _ in } receiveValue: { messages in // messages DispatchQueue.main.async { store.dispatch(.conversationAction(.messagesUpdated(messages: messages))) } // attachments var attachments: [Attachment] = [] for message in messages { do { try self.database._db.read { db in if let attachment = try message.attachment.fetchOne(db) { attachments.append(attachment) } } } catch { print("Failed to fetch attachment for message \(message.id): \(error)") } } DispatchQueue.main.async { store.dispatch(.conversationAction(.attachmentsUpdated(attachments: attachments))) } } .store(in: &conversationCancellables) } } // try db.write { db in // // Update the attachment // var attachment = try Attachment.fetchOne(db, key: attachmentId)! // attachment.someField = newValue // try attachment.update(db) // // // Update the message // var message = try Message.fetchOne(db, key: messageId)! // message.someField = newValue // try message.update(db) // }