diff --git a/kordophone2/App.swift b/kordophone2/App.swift index 70954b5..d498542 100644 --- a/kordophone2/App.swift +++ b/kordophone2/App.swift @@ -26,9 +26,6 @@ struct KordophoneApp: App ConversationListView(model: $conversationListModel) .frame(minWidth: 330.0) .xpcClient(xpcClient) - .task { - await refreshConversations() - } } detail: { ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel) .xpcClient(xpcClient) @@ -42,15 +39,6 @@ struct KordophoneApp: App } } - private func refreshConversations() async { - do { - let conversations = try await xpcClient.getConversations() - conversationListModel.conversations = conversations.map { Display.Conversation(from: $0) } - } catch { - reportError(error) - } - } - private func reportError(_ e: Error) { // Just printing for now. print("Error: \(e.localizedDescription)") diff --git a/kordophone2/ChatTranscriptView.swift b/kordophone2/ChatTranscriptView.swift index dc34536..52880b4 100644 --- a/kordophone2/ChatTranscriptView.swift +++ b/kordophone2/ChatTranscriptView.swift @@ -11,6 +11,8 @@ struct ChatTranscriptView: View { @Binding var model: ViewModel + @Environment(\.xpcClient) private var xpcClient + var body: some View { ScrollView { LazyVStack(spacing: 17.0) { @@ -18,11 +20,26 @@ struct ChatTranscriptView: View MessageCellView(message: message) .id(message.id) .scaleEffect(CGSize(width: 1.0, height: -1.0)) + .transition( + .push(from: .bottom) + .combined(with: .opacity) + ) } } .padding() } .scaleEffect(CGSize(width: 1.0, height: -1.0)) + .task { await watchForMessageListChanges() } + } + + private func watchForMessageListChanges() async { + for await event in xpcClient.eventStream() { + if case let .messagesUpdated(conversationId) = event { + if conversationId == model.displayedConversation { + model.setNeedsReload() + } + } + } } // MARK: - Types @@ -32,7 +49,8 @@ struct ChatTranscriptView: View { var messages: [Display.Message] var displayedConversation: Display.Conversation.ID? = nil - + var needsReload: Bool = true + init(messages: [Display.Message] = []) { self.messages = messages observeDisplayedConversation() @@ -45,21 +63,37 @@ struct ChatTranscriptView: View Task { @MainActor [weak self] in guard let self else { return } - await loadMessages() + setNeedsReload() observeDisplayedConversation() } } } + + func setNeedsReload() { + needsReload = true + Task { @MainActor [weak self] in + guard let self else { return } + await reloadMessages() + } + } - private func loadMessages() async { - self.messages = [] + func reloadMessages() async { + guard needsReload else { return } + needsReload = false guard let displayedConversation else { return } do { let client = XPCClient() - let messages = try await client.getMessages(conversationId: displayedConversation) - self.messages = messages.map { Display.Message(from: $0) } + let clientMessages = try await client.getMessages(conversationId: displayedConversation) + .map { Display.Message(from: $0) } + + let newMessages = Set(clientMessages).subtracting(Set(self.messages)) + + let animation: Animation? = newMessages.count == 1 ? .default : nil + withAnimation(animation) { + self.messages = clientMessages + } } catch { print("Message fetch error: \(error)") } diff --git a/kordophone2/ConversationListView.swift b/kordophone2/ConversationListView.swift index 2682cce..6feeb6e 100644 --- a/kordophone2/ConversationListView.swift +++ b/kordophone2/ConversationListView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ConversationListView: View { @Binding var model: ViewModel + @Environment(\.xpcClient) private var xpcClient var body: some View { List($model.conversations, selection: $model.selectedConversations) { conv in @@ -19,9 +20,26 @@ struct ConversationListView: View Text(conv.wrappedValue.messagePreview) } + .id(conv.id) .padding(8.0) } .listStyle(.sidebar) + .task { await watchForConversationListChanges() } + } + + private func watchForConversationListChanges() async { + for await event in xpcClient.eventStream() { + switch event { + case .conversationsUpdated: + model.setNeedsReload() + case .messagesUpdated(_): + model.setNeedsReload() + case .updateStreamReconnected: + model.setNeedsReload() + default: + break + } + } } // MARK: - Types @@ -32,9 +50,35 @@ struct ConversationListView: View var conversations: [Display.Conversation] var selectedConversations: Set + private var needsReload: Bool = true + public init(conversations: [Display.Conversation] = []) { self.conversations = conversations self.selectedConversations = Set() + setNeedsReload() + } + + func setNeedsReload() { + needsReload = true + Task { @MainActor [weak self] in + guard let self else { return } + await reloadConversations() + } + } + + func reloadConversations() async { + guard needsReload else { return } + needsReload = false + + do { + let client = XPCClient() + let clientConversations = try await client.getConversations() + .map { Display.Conversation(from: $0) } + + self.conversations = clientConversations + } catch { + print("Error reloading conversations: \(error)") + } } } } diff --git a/kordophone2/MessageEntryView.swift b/kordophone2/MessageEntryView.swift index 71b36d4..67844d8 100644 --- a/kordophone2/MessageEntryView.swift +++ b/kordophone2/MessageEntryView.swift @@ -10,7 +10,6 @@ import SwiftUI struct MessageEntryView: View { @Binding var viewModel: ViewModel - @Environment(\.selectedConversation) private var selectedConversation var body: some View { @@ -55,6 +54,8 @@ struct MessageEntryView: View guard !draftText.isEmpty else { return } let messageText = self.draftText + .trimmingCharacters(in: .whitespacesAndNewlines) + self.draftText = "" Task { diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift index 1bd5e26..9811c88 100644 --- a/kordophone2/Models.swift +++ b/kordophone2/Models.swift @@ -37,7 +37,7 @@ enum Display } } - struct Message: Identifiable + struct Message: Identifiable, Hashable { let id: String let sender: Sender @@ -63,6 +63,14 @@ enum Display self.sender = sender self.text = text } + + static func == (lhs: Message, rhs: Message) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } enum Sender { diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift index f99333a..b9a0b56 100644 --- a/kordophone2/XPC/XPCClient.swift +++ b/kordophone2/XPC/XPCClient.swift @@ -13,15 +13,47 @@ private let serviceName = "net.buzzert.kordophonecd" final class XPCClient { private let connection: xpc_connection_t + private let signalLock = NSLock() + private var signalSinks: [UUID: (Signal) -> Void] = [:] + private var didSubscribeSignals: Bool = false init() { self.connection = xpc_connection_create_mach_service(serviceName, nil, 0) - let handler: xpc_handler_t = { _ in } + let handler: xpc_handler_t = { [weak self] event in + self?.handleIncomingXPCEvent(event) + } + xpc_connection_set_event_handler(connection, handler) xpc_connection_resume(connection) } + public func eventStream() -> AsyncStream { + // Auto-subscribe on first stream creation + if !didSubscribeSignals { + didSubscribeSignals = true + Task { + try? await subscribeToSignals() + } + } + + return AsyncStream { continuation in + let id = UUID() + signalLock.withLock { + signalSinks[id] = { signal in + continuation.yield(signal) + } + } + + continuation.onTermination = { [weak self] _ in + guard let self else { return } + _ = self.signalLock.withLock { + self.signalSinks.removeValue(forKey: id) + } + } + } + } + public func getVersion() async throws -> String { let req = makeRequest(method: "GetVersion") guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } @@ -46,7 +78,8 @@ final class XPCClient if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let conv = Serialized.Conversation(xpc: element) { results.append(conv) } - return true + + return true } return results } @@ -66,6 +99,7 @@ final class XPCClient if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) { results.append(msg) } + return true } @@ -88,10 +122,24 @@ final class XPCClient case typeError case encodingError } + + enum Signal + { + case conversationsUpdated + case messagesUpdated(conversationId: String) + case attachmentDownloaded(attachmentId: String) + case attachmentUploaded(uploadGuid: String, attachmentGuid: String) + case updateStreamReconnected + } } extension XPCClient { + private func subscribeToSignals() async throws { + let req = makeRequest(method: "SubscribeSignals") + _ = try await sendSync(req) + } + private func makeRequest(method: String, arguments: [String: xpc_object_t]? = nil) -> xpc_object_t { let dict = xpc_dictionary_create(nil, nil, 0) xpc_dictionary_set_string(dict, "method", method) @@ -132,6 +180,52 @@ extension XPCClient } } } + + private func handleIncomingXPCEvent(_ event: xpc_object_t) { + let type = xpc_get_type(event) + switch type { + case XPC_TYPE_DICTIONARY: + guard let namePtr = xpc_dictionary_get_string(event, "name") else { return } + let name = String(cString: namePtr) + + var signal: Signal? + if name == "ConversationsUpdated" { + signal = .conversationsUpdated + } else if name == "MessagesUpdated" { + if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY, + let cidPtr = xpc_dictionary_get_string(args, "conversation_id") { + signal = .messagesUpdated(conversationId: String(cString: cidPtr)) + } + } else if name == "AttachmentDownloadCompleted" { + if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY, + let aidPtr = xpc_dictionary_get_string(args, "attachment_id") { + signal = .attachmentDownloaded(attachmentId: String(cString: aidPtr)) + } + } else if name == "AttachmentUploadCompleted" { + if let args = xpc_dictionary_get_value(event, "arguments"), xpc_get_type(args) == XPC_TYPE_DICTIONARY, + let ugPtr = xpc_dictionary_get_string(args, "upload_guid"), + let agPtr = xpc_dictionary_get_string(args, "attachment_guid") { + signal = .attachmentUploaded(uploadGuid: String(cString: ugPtr), attachmentGuid: String(cString: agPtr)) + } + } else if name == "UpdateStreamReconnected" { + signal = .updateStreamReconnected + } + + if let signal { + signalLock.lock() + let sinks = signalSinks.values + signalLock.unlock() + for sink in sinks { sink(signal) } + } + + case XPC_TYPE_ERROR: + if let errStr = xpc_string_get_string_ptr(event) { + print("XPC event error: \(String(cString: errStr))") + } + default: + break + } + } }