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