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