diff --git a/ConversationsClassic/AppData/AppError.swift b/ConversationsClassic/AppData/AppError.swift index 98b97f5..e90a5e0 100644 --- a/ConversationsClassic/AppData/AppError.swift +++ b/ConversationsClassic/AppData/AppError.swift @@ -9,4 +9,6 @@ enum AppError: Error { case invalidContentType case invalidPath case invalidLocalName + case moduleNotAvailable + case featureNotSupported } diff --git a/ConversationsClassic/AppData/Client/Client+MartinMAM.swift b/ConversationsClassic/AppData/Client/Client+MartinMAM.swift index 438f107..3487893 100644 --- a/ConversationsClassic/AppData/Client/Client+MartinMAM.swift +++ b/ConversationsClassic/AppData/Client/Client+MartinMAM.swift @@ -5,69 +5,29 @@ import Martin final class ClientMartinMAM { private var cancellables: Set = [] - private let messageProcessor: ArchivedMessageProcessor + + private weak var module: MessageArchiveManagementModule? + private var afterAvailable = true + private var beforeAvailable = true init(_ xmppConnection: XMPPClient) { - messageProcessor = ArchivedMessageProcessor() + module = xmppConnection.module(.mam) + subscribe() + } + private func subscribe() { // subscribe to archived messages - xmppConnection.module(.mam).archivedMessagesPublisher + module?.archivedMessagesPublisher + .delay(for: 0.7, scheduler: DispatchQueue.main) .sink(receiveValue: { [weak self] archived in guard let self = self else { return } Task { - await self.messageProcessor.addMessage(archived) + await self.handleMessage(archived) } }) .store(in: &cancellables) } - // func requestArchivedMessages(for roster: Roster, before: String? = nil, after: String? = nil, in module: MessageArchiveManagementModule) async { - // print(roster, before, after, module) - // - // // let endDate = Date() - // // let startDate = Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: endDate) ?? Date() - // // let response = try? await module.queryItems( - // // componentJid: JID(credentials.bareJid), - // // with: JID(roster.bareJid), - // // start: startDate, - // // end: endDate, - // // queryId: UUID().uuidString - // // ) - // // let query: RSM.Query = .init(before: nil, after: nil, max: nil) - // } - - // func requestArchivedMessages(for roster: Roster, before: String? = nil, after: String? = nil) async { - // assert(before != nil || after != nil, "Either before or after must be provided") - // if !discoManager.features.map({ $0.xep }).contains("XEP-0313") { - // return - // } - // let module = connection.module(MessageArchiveManagementModule.self) - // await mamManager.requestArchivedMessages(for: roster, before: before, after: after, in: module) - // } -} - -private actor ArchivedMessageProcessor { - private var messageBuffer: [Martin.MessageArchiveManagementModule.ArchivedMessageReceived] = [] - private let batchSize = 20 - - func addMessage(_ message: Martin.MessageArchiveManagementModule.ArchivedMessageReceived) async { - messageBuffer.append(message) - if messageBuffer.count >= batchSize { - await processBatch() - } - } - - private func processBatch() async { - guard !messageBuffer.isEmpty else { return } - - let batch = messageBuffer.prefix(batchSize) - messageBuffer.removeFirst(min(batchSize, messageBuffer.count)) - - for archived in batch { - await handleMessage(archived) - } - } - private func handleMessage(_ received: Martin.MessageArchiveManagementModule.ArchivedMessageReceived) async { let message = received.message let date = received.timestamp @@ -98,41 +58,3 @@ private actor ArchivedMessageProcessor { } } } - -// final class ClientMartinMAM { -// private var cancellables: Set = [] -// -// init(_ xmppConnection: XMPPClient) { -// // subscribe to archived messages -// xmppConnection.module(.mam).archivedMessagesPublisher -// .sink(receiveValue: { [weak self] archived in -// let message = archived.message -// message.attribute("archived_date", newValue: "\(archived.timestamp.timeIntervalSince1970)") -// self?.handleMessage(archived) -// }) -// .store(in: &cancellables) -// } -// -// private func handleMessage(_ received: Martin.MessageArchiveManagementModule.ArchivedMessageReceived) { -// let message = received.message -// let date = received.timestamp -// #if DEBUG -// print("---") -// print("Archive message received: \(message)") -// print("Date: \(date)") -// print("---") -// #endif -// -// if let msg = Message.map(message) { -// Task { -// do { -// var msg = msg -// msg.date = received.timestamp -// try await msg.save() -// } catch { -// logIt(.error, "Error saving message: \(error)") -// } -// } -// } -// } -// } diff --git a/ConversationsClassic/AppData/Client/Client.swift b/ConversationsClassic/AppData/Client/Client.swift index c6adf98..f8453c2 100644 --- a/ConversationsClassic/AppData/Client/Client.swift +++ b/ConversationsClassic/AppData/Client/Client.swift @@ -140,6 +140,14 @@ extension Client { throw URLError(.badServerResponse) } } + + func fetchArchiveMessages(for roster: Roster, query: RSM.Query) async throws -> Martin.MessageArchiveManagementModule.QueryResult { + if !discoManager.features.map({ $0.xep }).contains("XEP-0313") { + throw AppError.featureNotSupported + } + let module = connection.module(MessageArchiveManagementModule.self) + return try await module.queryItems(componentJid: JID(roster.bareJid), with: JID(roster.contactBareJid), queryId: UUID().uuidString, rsm: query) + } } extension Client { diff --git a/ConversationsClassic/AppData/Store/MessagesStore.swift b/ConversationsClassic/AppData/Store/MessagesStore.swift index d54576d..a33614a 100644 --- a/ConversationsClassic/AppData/Store/MessagesStore.swift +++ b/ConversationsClassic/AppData/Store/MessagesStore.swift @@ -1,8 +1,7 @@ -import AVFoundation import Combine import Foundation import GRDB -import Photos +import Martin @MainActor final class MessagesStore: ObservableObject { @@ -13,6 +12,7 @@ final class MessagesStore: ObservableObject { private let client: Client private var messagesCancellable: AnyCancellable? + private let archiveMessageFetcher = ArchiveMessageFetcher() init(roster: Roster, client: Client) { self.client = client @@ -21,6 +21,7 @@ final class MessagesStore: ObservableObject { } } +// MARK: - Send message extension MessagesStore { func sendMessage(_ message: String) { Task { @@ -49,6 +50,7 @@ extension MessagesStore { } } +// MARK: - Subscriptions private extension MessagesStore { func subscribe() { messagesCancellable = ValueObservation.tracking(Message @@ -64,6 +66,102 @@ private extension MessagesStore { .sink { _ in } receiveValue: { [weak self] messages in self?.messages = messages + if messages.isEmpty { + self?.requestLastArchivedMessages() + } } } } + +// MARK: - Archived messages +extension MessagesStore { + func requestEarliestArchivedMessages() { + guard let beforeId = messages.first?.id else { return } + Task { + await archiveMessageFetcher.fetchBeforeMessages(roster, client, beforeId: beforeId) + } + } + + func requestLatestArchivedMessages() { + guard let afterId = messages.first?.id else { return } + Task { + await archiveMessageFetcher.fetchAfterMessages(roster, client, afterId: afterId) + } + } + + private func requestLastArchivedMessages() { + Task { + await archiveMessageFetcher.fetchLastMessages(roster, client) + } + } +} + +private actor ArchiveMessageFetcher { + private var afterAvailable = true + private var beforeAvailable = true + private var isFetching = false + private var fetchingIsPossinle = true + + func fetchLastMessages(_ roster: Roster, _ client: Client) async { + if !fetchingIsPossinle { return } + while isFetching { + await Task.yield() + } + isFetching = true + + let query: RSM.Query = .init(lastItems: Const.mamRequestLimit) + do { + _ = try await client.fetchArchiveMessages(for: roster, query: query) + } catch AppError.featureNotSupported { + fetchingIsPossinle = false + } catch { + logIt(.error, "Error requesting archived messages: \(error)") + } + + isFetching = false + } + + func fetchBeforeMessages(_ roster: Roster, _ client: Client, beforeId: String) async { + if !fetchingIsPossinle || !beforeAvailable { return } + while isFetching { + await Task.yield() + } + isFetching = true + + let query: RSM.Query = .init(before: beforeId, max: Const.mamRequestLimit) + do { + let result = try await client.fetchArchiveMessages(for: roster, query: query) + if result.complete { + beforeAvailable = false + } + } catch AppError.featureNotSupported { + fetchingIsPossinle = false + } catch { + logIt(.error, "Error requesting archived messages: \(error)") + } + + isFetching = false + } + + func fetchAfterMessages(_ roster: Roster, _ client: Client, afterId: String) async { + if !fetchingIsPossinle || !afterAvailable { return } + while isFetching { + await Task.yield() + } + isFetching = true + + let query: RSM.Query = .init(after: afterId, max: Const.mamRequestLimit) + do { + let result = try await client.fetchArchiveMessages(for: roster, query: query) + if result.complete { + afterAvailable = false + } + } catch AppError.featureNotSupported { + fetchingIsPossinle = false + } catch { + logIt(.error, "Error requesting archived messages: \(error)") + } + + isFetching = false + } +} diff --git a/ConversationsClassic/Helpers/Const.swift b/ConversationsClassic/Helpers/Const.swift index 67fef11..c9fd53a 100644 --- a/ConversationsClassic/Helpers/Const.swift +++ b/ConversationsClassic/Helpers/Const.swift @@ -43,6 +43,6 @@ enum Const { // Size for attachment preview static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5 - // Lenght in days for MAM request - static let mamRequestDaysLength = 30 + // MAM request page size + static let mamRequestLimit = 30 } diff --git a/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift b/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift index be88ee3..9dc70e6 100644 --- a/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift +++ b/ConversationsClassic/View/Main/ChatList/ChatsListScreen.swift @@ -62,7 +62,7 @@ private struct ChatsRow: View { do { let (messages, attachments) = try await clientsStore.conversationStores(for: chat) router.showScreen(.push) { _ in - ConversationScreen(messages: messages, attachments: attachments) + ConversationScreen(messagesStore: messages, attachments: attachments) .navigationBarHidden(true) } } catch { diff --git a/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift b/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift index 474a800..be31e01 100644 --- a/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift +++ b/ConversationsClassic/View/Main/Contacts/ContactsScreen.swift @@ -160,7 +160,7 @@ private struct ContactsScreenRow: View { do { let (messages, attachments) = try await clientsStore.conversationStores(for: roster) router.showScreen(.push) { _ in - ConversationScreen(messages: messages, attachments: attachments) + ConversationScreen(messagesStore: messages, attachments: attachments) .navigationBarHidden(true) } } catch { diff --git a/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift index 7a1671a..cc3c0f8 100644 --- a/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationScreen.swift @@ -5,7 +5,7 @@ import SwiftUI struct ConversationScreen: View { @Environment(\.router) var router - @StateObject var messages: MessagesStore + @StateObject var messagesStore: MessagesStore @StateObject var attachments: AttachmentsStore @State private var autoScroll = true @@ -31,7 +31,7 @@ struct ConversationScreen: View { ) // Msg list - let messages = messages.messages + let messages = messagesStore.messages if !messages.isEmpty { ScrollViewReader { proxy in ScrollView { @@ -45,6 +45,9 @@ struct ConversationScreen: View { firstIsVisible = true autoScroll = true } + if message.id == messages.last?.id { + messagesStore.requestEarliestArchivedMessages() + } } .onDisappear { if message.id == messages.first?.id { @@ -97,11 +100,11 @@ struct ConversationScreen: View { } } } - .environmentObject(messages) + .environmentObject(messagesStore) .environmentObject(attachments) .safeAreaInset(edge: .bottom, spacing: 0) { ConversationTextInput(autoScroll: $autoScroll) - .environmentObject(messages) + .environmentObject(messagesStore) .environmentObject(attachments) } }