Adds getting/sending messages
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
21
kordophone2/ConversationView.swift
Normal file
21
kordophone2/ConversationView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
kordophone2/MessageEntryView.swift
Normal file
80
kordophone2/MessageEntryView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user