import SwiftUI /// Nearlight transcript: a legible shared record. Full-width rows, no /// bubbles; metadata row per event; "Live" placeholder on partials. struct TranscriptListView: View { let events: [TranscriptEvent] var onClear: () -> Void @State private var confirmClear = false @State private var followingLatest = true @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"# var body: some View { ScrollViewReader { proxy in VStack(spacing: 0) { HStack { Text("Conversation") .font(NearlightFont.support(typeSize).weight(.semibold)) .foregroundStyle(Nearlight.quiet) Spacer() Menu { Button("Clear conversation", systemImage: "trash", role: .destructive) { confirmClear = true } } label: { Image(systemName: "ellipsis").frame(width: 44, height: 44) } .accessibilityLabel("Conversation options") .disabled(events.isEmpty) } .padding(.horizontal, Nearlight.s20) ScrollView { LazyVStack(alignment: .leading, spacing: Nearlight.s24) { if events.isEmpty { Text("Your conversation will appear here.") .font(.body) .foregroundStyle(Nearlight.quiet) .padding(.top, Nearlight.s24) } ForEach(events) { event in transcriptRow(event) } Color.clear.frame(height: 1).id("latest") .onAppear { followingLatest = true } .onDisappear { followingLatest = false } } .padding(.horizontal, Nearlight.s20) .padding(.top, Nearlight.s16) .padding(.bottom, Nearlight.s20) .frame(minHeight: 200, alignment: .topLeading) } .scrollDismissesKeyboard(.interactively) .defaultScrollAnchor(.top) .onChange(of: events.last?.text) { _, _ in if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: events.count) { _, _ in if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } } if !followingLatest && !events.isEmpty { Button { withAnimation(reduceMotion ? nil : Nearlight.standard) { proxy.scrollTo("latest", anchor: .bottom) } } label: { Label("Latest messages", systemImage: "arrow.down") .font(NearlightFont.support(typeSize)) .frame(minHeight: 44) } .accessibilityIdentifier("talk.latest") } } } .alert("Clear this conversation?", isPresented: $confirmClear) { Button("Clear conversation", role: .destructive, action: onClear) Button("Cancel", role: .cancel) {} } message: { Text("This removes the transcript saved on this iPhone. It doesn’t reset Hope’s memory or end your current conversation.") } } private func transcriptRow(_ event: TranscriptEvent) -> some View { let isUser = event.role == .user let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) return VStack(alignment: .leading, spacing: Nearlight.s8) { // Metadata row: speaker, timestamp trailing. Stacks at // accessibility sizes per spec. Group { if typeSize.isAccessibilitySize { VStack(alignment: .leading, spacing: Nearlight.s4) { speakerLabel(isUser) timeLabel(event) } } else { HStack(spacing: Nearlight.s8) { speakerLabel(isUser) timeLabel(event) } } } Text(text) .font(.body) .foregroundStyle(Nearlight.ink) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } } .accessibilityElement(children: .combine) .id(event.id) } private func speakerLabel(_ isUser: Bool) -> some View { Text(isUser ? "You" : "Hope") .font(NearlightFont.metadata(typeSize).weight(.semibold)) .foregroundStyle(Nearlight.ink) } private func timeLabel(_ event: TranscriptEvent) -> some View { Group { if event.isFinal { Text(event.timestamp, style: .time) } else { Text("Live") } } .font(NearlightFont.metadata(typeSize)) .foregroundStyle(Nearlight.quiet) .monospacedDigit() } }