Adds getting/sending messages
This commit is contained in:
@@ -11,19 +11,33 @@ import SwiftUI
|
|||||||
struct KordophoneApp: App
|
struct KordophoneApp: App
|
||||||
{
|
{
|
||||||
@State var conversationListModel = ConversationListView.ViewModel()
|
@State var conversationListModel = ConversationListView.ViewModel()
|
||||||
|
@State var transcriptViewModel = ChatTranscriptView.ViewModel()
|
||||||
|
@State var entryViewModel = MessageEntryView.ViewModel()
|
||||||
|
|
||||||
private let xpcClient = XPCClient()
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
ConversationListView(model: $conversationListModel)
|
ConversationListView(model: $conversationListModel)
|
||||||
.frame(minWidth: 330.0)
|
.frame(minWidth: 330.0)
|
||||||
|
.xpcClient(xpcClient)
|
||||||
.task {
|
.task {
|
||||||
await refreshConversations()
|
await refreshConversations()
|
||||||
}
|
}
|
||||||
} detail: {
|
} 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)")
|
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.
|
// 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
|
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
|
enum Serialized
|
||||||
@@ -70,4 +104,26 @@ enum Serialized
|
|||||||
self.date = dt
|
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) {
|
if xpc_get_type(element) == XPC_TYPE_DICTIONARY, let conv = Serialized.Conversation(xpc: element) {
|
||||||
results.append(conv)
|
results.append(conv)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return results
|
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
|
// MARK: - Types
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user