Files
hope-voice-api/Sources/Features/Conversation/TranscriptListView.swift
T

132 lines
5.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
.scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.bottom)
.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()
}
}