- Transcript: selection suspension clears when press ends; Latest re-enables follow fully (clears stale selection state) - Send disabled until transport ready in pending Type composer - Server URL: programmatic success-trim no longer erases its own saved-confirmation; only user edits clear it
166 lines
7.4 KiB
Swift
166 lines
7.4 KiB
Swift
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
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@Environment(\.dynamicTypeSize) private var typeSize
|
||
|
||
/// Auto-follow eligibility: never scroll while the user is selecting
|
||
/// text or reading history.
|
||
private var isFollowingEligible: Bool {
|
||
followingLatest && !selectionActive
|
||
}
|
||
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")
|
||
}
|
||
.padding(.horizontal, Nearlight.s20)
|
||
.padding(.top, Nearlight.s16)
|
||
.padding(.bottom, Nearlight.s20)
|
||
.frame(minHeight: 200, alignment: .topLeading)
|
||
}
|
||
.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) { _, _ in
|
||
guard isFollowingEligible else { return }
|
||
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 }
|
||
}
|
||
if !followingLatest && !events.isEmpty {
|
||
HStack {
|
||
Spacer()
|
||
Button {
|
||
// Explicit Latest restores full eligibility,
|
||
// clearing any stale selection suspension.
|
||
selectionActive = false
|
||
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 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)
|
||
.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()
|
||
}
|
||
}
|