// // TranscriptView.swift // kordophone2 // // Created by James Magahern on 8/24/25. // import SwiftUI struct TranscriptView: View { @Binding var model: ViewModel @Environment(\.xpcClient) private var xpcClient var body: some View { ScrollView { LazyVStack(spacing: 17.0) { ForEach($model.displayItems.reversed()) { item in displayItemView(item.wrappedValue) .id(item.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() { switch event { case .attachmentDownloaded(let attachmentId): model.attachmentDownloaded(id: attachmentId) case .messagesUpdated(let conversationId): if conversationId == model.displayedConversation { model.setNeedsReload(animated: true) } case .updateStreamReconnected: await model.triggerSync() default: break } } } @ViewBuilder private func displayItemView(_ item: DisplayItem) -> some View { switch item { case .message(let message): TextBubbleItemView(text: message.text, isFromMe: message.isFromMe) case .date(let date): DateItemView(date: date) case .attachment(let attachment): if attachment.isPreviewDownloaded { ImageItemView( isFromMe: attachment.sender.isMe, attachment: attachment ) } else { PlaceholderImageItemView( isFromMe: attachment.sender.isMe, size: attachment.size ) } } } // MARK: - Types @Observable class ViewModel { var displayItems: [DisplayItem] = [] var displayedConversation: Display.Conversation.ID? = nil internal var needsReload: NeedsReload = .no internal var messages: [Display.Message] internal let client = XPCClient() init(messages: [Display.Message] = []) { self.messages = messages observeDisplayedConversation() rebuildDisplayItems() } func setNeedsReload(animated: Bool) { guard case .no = needsReload else { return } needsReload = .yes(animated) Task { @MainActor [weak self] in guard let self else { return } await reloadMessages() } } func attachmentDownloaded(id: String) { // TODO: should be smarter here setNeedsReload(animated: true) } private func observeDisplayedConversation() { withObservationTracking { _ = displayedConversation } onChange: { Task { @MainActor [weak self] in guard let self else { return } await triggerSync() setNeedsReload(animated: false) observeDisplayedConversation() } } } func triggerSync() async { guard let displayedConversation else { return } do { try await client.syncConversation(conversationId: displayedConversation) } catch { print("Error triggering sync: \(error)") } } private func reloadMessages() async { guard case .yes(let animated) = needsReload else { return } needsReload = .no guard let displayedConversation else { return } do { let clientMessages = try await client.getMessages(conversationId: displayedConversation) .map { Display.Message(from: $0) } self.messages = clientMessages self.rebuildDisplayItems(animated: animated) } catch { print("Message fetch error: \(error)") } } // MARK: - Types enum NeedsReload { case no case yes(Bool) // animated } } } #Preview { @Previewable @State var model = TranscriptView.ViewModel(messages: [ .init(sender: .me, text: "Hello, how are you?"), .init(sender: .counterpart("Bob"), text: "I am doing fine!") ]) TranscriptView(model: $model) }