From 3ee94a3bea0cb8b0b7d987dbd556cf1471f923ad Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 17:58:37 -0700 Subject: [PATCH] Adds getting/sending messages --- kordophone2/App.swift | 34 +++++++- kordophone2/ChatTranscriptView.swift | 124 +++++++++++++++++++++++++++ kordophone2/ConversationView.swift | 21 +++++ kordophone2/MessageEntryView.swift | 80 +++++++++++++++++ kordophone2/Models.swift | 56 ++++++++++++ kordophone2/XPC/XPCClient.swift | 32 ++++++- 6 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 kordophone2/ConversationView.swift create mode 100644 kordophone2/MessageEntryView.swift diff --git a/kordophone2/App.swift b/kordophone2/App.swift index 82e0dcb..70954b5 100644 --- a/kordophone2/App.swift +++ b/kordophone2/App.swift @@ -11,19 +11,33 @@ import SwiftUI struct KordophoneApp: App { @State var conversationListModel = ConversationListView.ViewModel() + @State var transcriptViewModel = ChatTranscriptView.ViewModel() + @State var entryViewModel = MessageEntryView.ViewModel() private let xpcClient = XPCClient() + private var selectedConversation: Display.Conversation? { + guard let id = conversationListModel.selectedConversations.first else { return nil } + return conversationListModel.conversations.first { $0.id == id } + } var body: some Scene { WindowGroup { NavigationSplitView { ConversationListView(model: $conversationListModel) .frame(minWidth: 330.0) + .xpcClient(xpcClient) .task { await refreshConversations() } } detail: { - // Detail + ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel) + .xpcClient(xpcClient) + .selectedConversation(selectedConversation) + .navigationTitle("Kordophone") + .navigationSubtitle(selectedConversation?.displayName ?? "") + .onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in + transcriptViewModel.displayedConversation = newValue.first + } } } } @@ -42,3 +56,21 @@ struct KordophoneApp: App print("Error: \(e.localizedDescription)") } } + +extension EnvironmentValues +{ + @Entry var xpcClient: XPCClient = XPCClient() + @Entry var selectedConversation: Display.Conversation? = nil +} + +extension View +{ + func xpcClient(_ client: XPCClient) -> some View { + environment(\.xpcClient, client) + } + + func selectedConversation(_ convo: Display.Conversation?) -> some View { + environment(\.selectedConversation, convo) + } +} + diff --git a/kordophone2/ChatTranscriptView.swift b/kordophone2/ChatTranscriptView.swift index 69e63f1..dc34536 100644 --- a/kordophone2/ChatTranscriptView.swift +++ b/kordophone2/ChatTranscriptView.swift @@ -5,3 +5,127 @@ // Created by James Magahern on 8/24/25. // +import SwiftUI + +struct ChatTranscriptView: View +{ + @Binding var model: ViewModel + + var body: some View { + ScrollView { + LazyVStack(spacing: 17.0) { + ForEach($model.messages.reversed()) { message in + MessageCellView(message: message) + .id(message.id) + .scaleEffect(CGSize(width: 1.0, height: -1.0)) + } + } + .padding() + } + .scaleEffect(CGSize(width: 1.0, height: -1.0)) + } + + // MARK: - Types + + @Observable + class ViewModel + { + var messages: [Display.Message] + var displayedConversation: Display.Conversation.ID? = nil + + init(messages: [Display.Message] = []) { + self.messages = messages + observeDisplayedConversation() + } + + private func observeDisplayedConversation() { + withObservationTracking { + _ = displayedConversation + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + + await loadMessages() + observeDisplayedConversation() + } + } + } + + private func loadMessages() async { + self.messages = [] + + 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) } + } catch { + print("Message fetch error: \(error)") + } + } + } +} + +struct MessageCellView: View +{ + @Binding var message: Display.Message + + var body: some View { + VStack { + HStack(alignment: .bottom) { + if message.isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } + + TextCellContentView(text: message.text, isFromMe: message.isFromMe) + .mask { + UnevenRoundedRectangle(cornerRadii: RectangleCornerRadii( + topLeading: message.isFromMe ? .dominantCornerRadius : .minorCornerRadius, + bottomLeading: .dominantCornerRadius, + bottomTrailing: .dominantCornerRadius, + topTrailing: message.isFromMe ? .minorCornerRadius : .dominantCornerRadius, + )) + } + + if !message.isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } + } + } + } + + // MARK: - Types + + private struct TextCellContentView: View + { + let text: String + let isFromMe: Bool + + var body: some View { + let bubbleColor: Color = isFromMe ? .blue : Color(.systemGray) + let textColor: Color = isFromMe ? .white : .primary + + HStack { + Text(text) + .foregroundStyle(textColor) + .multilineTextAlignment(.leading) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16.0) + .padding(.vertical, 10.0) + .background(bubbleColor) + } + } +} + +fileprivate extension CGFloat { + static let dominantCornerRadius = 16.0 + static let minorCornerRadius = 4.0 + static let minimumBubbleHorizontalPadding = 80.0 +} + +#Preview { + @Previewable @State var model = ChatTranscriptView.ViewModel(messages: [ + .init(sender: .me, text: "Hello, how are you?"), + .init(sender: .counterpart("Bob"), text: "I am doing fine!") + ]) + + ChatTranscriptView(model: $model) +} diff --git a/kordophone2/ConversationView.swift b/kordophone2/ConversationView.swift new file mode 100644 index 0000000..bf6044f --- /dev/null +++ b/kordophone2/ConversationView.swift @@ -0,0 +1,21 @@ +// +// ConversationView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct ConversationView: View +{ + @Binding var transcriptModel: ChatTranscriptView.ViewModel + @Binding var entryModel: MessageEntryView.ViewModel + + var body: some View { + VStack { + ChatTranscriptView(model: $transcriptModel) + MessageEntryView(viewModel: $entryModel) + } + } +} diff --git a/kordophone2/MessageEntryView.swift b/kordophone2/MessageEntryView.swift new file mode 100644 index 0000000..71b36d4 --- /dev/null +++ b/kordophone2/MessageEntryView.swift @@ -0,0 +1,80 @@ +// +// MessageEntryView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct MessageEntryView: View +{ + @Binding var viewModel: ViewModel + + @Environment(\.selectedConversation) private var selectedConversation + + var body: some View { + VStack(spacing: 0.0) { + Rectangle() + .fill(.separator) + .frame(height: 1.0) + + HStack { + TextField("", text: $viewModel.draftText, axis: .vertical) + .focusEffectDisabled(true) + .lineLimit(nil) + .scrollContentBackground(.hidden) + .fixedSize(horizontal: false, vertical: true) + .font(.body) + .scrollDisabled(true) + .padding(8.0) + .background { + RoundedRectangle(cornerRadius: 6.0) + .fill(.background) + } + + Button("Send") { + viewModel.sendDraft(to: selectedConversation) + } + .disabled(viewModel.draftText.isEmpty) + .keyboardShortcut(.defaultAction) + } + .padding(10.0) + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var draftText: String = "" + + func sendDraft(to convo: Display.Conversation?) { + guard let convo else { return } + guard !draftText.isEmpty else { return } + + let messageText = self.draftText + self.draftText = "" + + Task { + let xpc = XPCClient() + + do { + try await xpc.sendMessage(conversationId: convo.id, message: messageText) + } catch { + print("Sending error: \(error)") + } + } + } + } +} + +#Preview { + @Previewable @State var model = MessageEntryView.ViewModel() + + VStack { + Spacer() + MessageEntryView(viewModel: $model) + } +} diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift index e71041f..1bd5e26 100644 --- a/kordophone2/Models.swift +++ b/kordophone2/Models.swift @@ -36,6 +36,40 @@ enum Display self.messagePreview = messagePreview } } + + struct Message: Identifiable + { + let id: String + let sender: Sender + let text: String + + var isFromMe: Bool { + if case .me = sender { true } + else { false } + } + + init(from m: Serialized.Message) { + self.id = m.guid + self.text = m.text + self.sender = if m.sender == "(Me)" { + .me + } else { + .counterpart(m.sender) + } + } + + init(id: String = UUID().uuidString, sender: Sender = .me, text: String) { + self.id = id + self.sender = sender + self.text = text + } + + enum Sender + { + case me + case counterpart(String) + } + } } enum Serialized @@ -70,4 +104,26 @@ enum Serialized self.date = dt } } + + struct Message: Decodable + { + let guid: String + let sender: String + let text: String + let date: Date + + init?(xpc dict: xpc_object_t) + { + guard let g: String = dict["id"] else { return nil } + + let s: String = dict["sender"] ?? "" + let t: String = dict["text"] ?? "" + let d: Date = dict["date"] ?? Date(timeIntervalSince1970: 0) + + self.guid = g + self.sender = s + self.text = t + self.date = d + } + } } diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift index d82c263..f99333a 100644 --- a/kordophone2/XPC/XPCClient.swift +++ b/kordophone2/XPC/XPCClient.swift @@ -46,10 +46,40 @@ 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 } + + public func getMessages(conversationId: String, limit: Int = 100, offset: Int = 0) async throws -> [Serialized.Message] { + var args: [String: xpc_object_t] = [:] + args["conversation_id"] = xpcString(conversationId) + args["limit"] = xpcString(String(limit)) + args["offset"] = xpcString(String(offset)) + + let req = makeRequest(method: "GetMessages", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { return [] } + guard let items = xpc_dictionary_get_value(reply, "messages"), xpc_get_type(items) == XPC_TYPE_ARRAY else { return [] } + + var results: [Serialized.Message] = [] + xpc_array_apply(items) { _, element in + if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let msg = Serialized.Message(xpc: element) { + results.append(msg) + } + return true + } + + return results + } + + public func sendMessage(conversationId: String, message: String) async throws { + var args: [String: xpc_object_t] = [:] + args["conversation_id"] = xpcString(conversationId) + args["text"] = xpcString(message) + + let req = makeRequest(method: "SendMessage", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } + } // MARK: - Types