// // 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 init(model: Binding) { self._model = model } var body: some View { ScrollViewReader { proxy in ScrollView { // For resetting scroll position to the "bottom" EmptyView() .id(ViewID.bottomAnchor) LazyVStack(spacing: 6.0) { ForEach($model.displayItems.reversed()) { item in displayItemView(item.wrappedValue) .id(item.id) .scaleEffect(CGSize(width: 1.0, height: -1.0)) .transition( .push(from: .top) .combined(with: .opacity) ) } } .padding() } // Flip vertically so newest messages are at the bottom. .scaleEffect(CGSize(width: 1.0, height: -1.0)) // Watch for xpc events .task { await watchForMessageListChanges() } // On conversation change, reload displayed messages and mark as read. .onChange(of: model.displayedConversation) { oldValue, newValue in Task { guard oldValue != newValue else { return } // Reload NOW await model.reloadMessages(animated: false) { // Once that's done, scroll to the "bottom" (actually top) proxy.scrollTo(ViewID.bottomAnchor, anchor: .top) } } Task.detached { // Mark as read on server, and trigger a sync. await model.markAsRead() await model.triggerSync() } } } } 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 let displayedConversation = model.displayedConversation, conversationId == displayedConversation.id { 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, sender: message.sender, date: message.date) case .date(let date): DateItemView(date: date) case .senderAttribition(let message): SenderAttributionView(sender: message.sender) case .spacer(let length, _): Spacer(minLength: length) case .attachment(let attachment): if attachment.isPreviewDownloaded { ImageItemView( sender: attachment.sender, date: attachment.dateSent, attachment: attachment ) } else { PlaceholderImageItemView( sender: attachment.sender, date: attachment.dateSent, size: attachment.size ) } } } // MARK: - Types enum ViewID: String { case bottomAnchor } // MARK: - View Model @Observable class ViewModel { var displayItems: [DisplayItem] = [] var displayedConversation: Display.Conversation? = nil internal var needsReload: NeedsReload = .no internal var messages: [Display.Message] internal let client = XPCClient() private var needsMarkAsRead: Bool = false private var lastMarkAsRead: Date = .now init(messages: [Display.Message] = []) { self.messages = messages 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 reloadIfNeeded() } } func setNeedsMarkAsRead() { guard needsMarkAsRead == false else { return } guard Date.now.timeIntervalSince(lastMarkAsRead) > 5.0 else { return } needsMarkAsRead = true Task { @MainActor [weak self] in guard let self else { return } await markAsRead() needsMarkAsRead = false lastMarkAsRead = .now } } func attachmentDownloaded(id: String) { // TODO: should be smarter here setNeedsReload(animated: false) } func markAsRead() async { guard let displayedConversation else { return } do { try await client.markConversationAsRead(conversationId: displayedConversation.id) } catch { print("Error triggering sync: \(error)") } } func triggerSync() async { guard let displayedConversation else { return } do { try await client.syncConversation(conversationId: displayedConversation.id) } catch { print("Error triggering sync: \(error)") } } func reloadIfNeeded(completion: () -> Void = {}) async { guard case .yes(let animated) = needsReload else { return } needsReload = .no await reloadMessages(animated: animated, completion: completion) } func reloadMessages(animated: Bool, completion: () -> Void) async { guard let displayedConversation else { return } do { let clientMessages = try await client.getMessages(conversationId: displayedConversation.id) .map { Display.Message(from: $0) } let newIds = Set(clientMessages.map(\.id)) .subtracting(self.messages.map(\.id)) // Only animate for incoming messages. let shouldAnimate = (newIds.count == 1) await MainActor.run { self.messages = clientMessages self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion) } } 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) }