Private
Public Access
1
0

Adds getting/sending messages

This commit is contained in:
2025-08-24 17:58:37 -07:00
parent b5a2f318b4
commit 3ee94a3bea
6 changed files with 345 additions and 2 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -46,11 +46,41 @@ 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
enum Error: Swift.Error