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 @State private var selectionActive = false @State private var viewportHeight: CGFloat = 0 @State private var latestBottom: CGFloat = 0 @AccessibilityFocusState private var focusedEventID: String? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize init(events: [TranscriptEvent], onClear: @escaping () -> Void) { self.events = events self.onClear = onClear _followingLatest = State(initialValue: events.isEmpty) } /// Auto-follow eligibility: never scroll while the user is selecting /// text or reading history. private var isFollowingEligible: Bool { followingLatest && !selectionActive && focusedEventID == nil } 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) } GeometryReader { geometry in Color.clear.preference( key: TranscriptLatestBottomKey.self, value: geometry.frame(in: .named("transcriptScroll")).maxY ) } .frame(height: 1) .id("latest") } .padding(.horizontal, Nearlight.s20) .padding(.top, Nearlight.s16) .padding(.bottom, Nearlight.s20) .frame(minHeight: 200, alignment: .topLeading) } .coordinateSpace(name: "transcriptScroll") .background { GeometryReader { geometry in Color.clear.preference( key: TranscriptViewportHeightKey.self, value: geometry.size.height ) } } .scrollDismissesKeyboard(.interactively) .defaultScrollAnchor(.top) .simultaneousGesture(DragGesture().onChanged { _ in // Any manual drag suspends automatic following. followingLatest = false }) .onChange(of: events.last?.text) { _, _ in guard isFollowingEligible else { return } proxy.scrollTo("latest", anchor: .bottom) } .onChange(of: events.count) { _, count in if count == 0 { selectionActive = false followingLatest = true } else if isFollowingEligible { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: selectionActive) { _, active in // Beginning text selection suspends auto-follow so the // user's reading position is never disturbed. if active { followingLatest = false } } .onChange(of: focusedEventID) { _, eventID in // VoiceOver focus on any transcript row means the user is // reading history. Only Latest resumes automatic following. if eventID != nil { followingLatest = false } } .onPreferenceChange(TranscriptViewportHeightKey.self) { height in viewportHeight = height updateFollowingEligibility(bottom: latestBottom, viewport: height) } .onPreferenceChange(TranscriptLatestBottomKey.self) { bottom in latestBottom = bottom updateFollowingEligibility(bottom: bottom, viewport: viewportHeight) } if !followingLatest && !events.isEmpty { HStack { Spacer() Button { // Explicit Latest restores full eligibility, // clearing any stale selection suspension. selectionActive = false focusedEventID = nil followingLatest = true withAnimation(reduceMotion ? nil : Nearlight.standard) { proxy.scrollTo("latest", anchor: .bottom) } } label: { Label("Latest", systemImage: "arrow.down") .font(NearlightFont.support(typeSize)) .padding(.horizontal, Nearlight.s12) .frame(minHeight: 44) .background(Nearlight.surface, in: Capsule()) .overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)) } .accessibilityIdentifier("talk.latest") } .padding(.horizontal, Nearlight.s12) .padding(.bottom, Nearlight.s12) } } } .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 updateFollowingEligibility(bottom: CGFloat, viewport: CGFloat) { guard bottom > 0, viewport > 0, !selectionActive, focusedEventID == nil else { return } let shouldFollow = bottom <= viewport + 48 if followingLatest != shouldFollow { followingLatest = shouldFollow } } 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) .onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in // Text-selection interaction begins with long-press on the row; // suspend auto-follow while pressing/held and restore when the // press ends so following can resume. selectionActive = pressing }, perform: {}) .contextMenu { Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } } .accessibilityElement(children: .combine) .accessibilityFocused($focusedEventID, equals: event.id) .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() } } private struct TranscriptViewportHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } private struct TranscriptLatestBottomKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } }