Private
Public Access
1
0
Files
Kordophone/kordophone2/ChatTranscriptView.swift

166 lines
5.1 KiB
Swift
Raw Normal View History

2025-08-24 16:24:21 -07:00
//
// ChatTranscriptView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
2025-08-24 17:58:37 -07:00
import SwiftUI
struct ChatTranscriptView: View
{
@Binding var model: ViewModel
2025-08-24 18:41:42 -07:00
@Environment(\.xpcClient) private var xpcClient
2025-08-24 17:58:37 -07:00
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))
2025-08-24 18:41:42 -07:00
.transition(
.push(from: .bottom)
.combined(with: .opacity)
)
2025-08-24 17:58:37 -07:00
}
}
.padding()
}
.scaleEffect(CGSize(width: 1.0, height: -1.0))
2025-08-24 18:41:42 -07:00
.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()
}
}
}
2025-08-24 17:58:37 -07:00
}
// MARK: - Types
@Observable
class ViewModel
{
var messages: [Display.Message]
var displayedConversation: Display.Conversation.ID? = nil
2025-08-24 18:41:42 -07:00
var needsReload: Bool = true
2025-08-24 17:58:37 -07:00
init(messages: [Display.Message] = []) {
self.messages = messages
observeDisplayedConversation()
}
private func observeDisplayedConversation() {
withObservationTracking {
_ = displayedConversation
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
2025-08-24 18:41:42 -07:00
setNeedsReload()
2025-08-24 17:58:37 -07:00
observeDisplayedConversation()
}
}
}
2025-08-24 18:41:42 -07:00
func setNeedsReload() {
needsReload = true
Task { @MainActor [weak self] in
guard let self else { return }
await reloadMessages()
}
}
2025-08-24 17:58:37 -07:00
2025-08-24 18:41:42 -07:00
func reloadMessages() async {
guard needsReload else { return }
needsReload = false
2025-08-24 17:58:37 -07:00
guard let displayedConversation else { return }
do {
let client = XPCClient()
2025-08-24 18:41:42 -07:00
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
}
2025-08-24 17:58:37 -07:00
} 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)
}