Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 173 additions & 3 deletions apps/swift-ios/Features/Chat/ThreadDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,6 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
)
collectionView.backgroundColor = T3Colors.uiBackground
collectionView.alwaysBounceVertical = true
collectionView.keyboardDismissMode = .onDrag
collectionView.delaysContentTouches = false
collectionView.contentInsetAdjustmentBehavior = .never
collectionView.isPrefetchingEnabled = true
Expand Down Expand Up @@ -710,7 +709,9 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
}

@MainActor
final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, UICollectionViewDelegate {
final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching,
UICollectionViewDelegate, UIGestureRecognizerDelegate
{
private struct MarkdownPrefetch {
let revision: MarkdownContentRevision
let task: Task<Void, Never>
Expand All @@ -731,12 +732,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
private var markdownPrefetches: [String: MarkdownPrefetch] = [:]
private var onLoadEarlier: (() -> Void)?
private var onDismissKeyboard: (() -> Void)?
private let timestampReveal = FeatureTimestampRevealState()
private var verticalDragStartOffset: CGFloat?
private lazy var timestampPanGesture = UIPanGestureRecognizer(
target: self,
action: #selector(handleTimestampPan(_:))
)

deinit {
markdownPrefetches.values.forEach { $0.task.cancel() }
}

func connect(to collectionView: UICollectionView) {
timestampPanGesture.cancelsTouchesInView = false
timestampPanGesture.delegate = self
collectionView.addGestureRecognizer(timestampPanGesture)

let registration = UICollectionView.CellRegistration<UICollectionViewCell, String> {
[weak self] cell, _, messageID in
if messageID == FeatureTranscriptCollectionView.loadEarlierID {
Expand Down Expand Up @@ -770,7 +781,10 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
}

cell.contentConfiguration = UIHostingConfiguration {
FeatureMessageView(message: message)
FeatureTimestampRevealMessageView(
message: message,
reveal: self?.timestampReveal ?? FeatureTimestampRevealState()
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.margins(.all, 0)
Expand All @@ -791,6 +805,71 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
collectionView.delegate = self
}

@objc private func handleTimestampPan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
timestampReveal.width = TranscriptTimestampRevealGeometry.width(
translationX: gesture.translation(in: gesture.view).x
)
case .ended, .cancelled, .failed:
guard timestampReveal.width > 0 else { return }
let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.22
withAnimation(.easeOut(duration: duration)) {
timestampReveal.width = 0
}
default:
break
}
}

func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === timestampPanGesture,
let collectionView = gestureRecognizer.view as? UICollectionView,
let pan = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = pan.velocity(in: collectionView)
guard collectionView.effectiveUserInterfaceLayoutDirection == .leftToRight else {
return false
}
return TranscriptTimestampRevealGeometry.shouldBegin(
velocityX: velocity.x,
velocityY: velocity.y
) && !isNestedHorizontalScroller(
at: pan.location(in: collectionView),
in: collectionView
)
}

func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
guard let collectionView = timestampPanGesture.view as? UICollectionView else {
return false
}
return (gestureRecognizer === timestampPanGesture
&& otherGestureRecognizer === collectionView.panGestureRecognizer)
|| (otherGestureRecognizer === timestampPanGesture
&& gestureRecognizer === collectionView.panGestureRecognizer)
}

private func isNestedHorizontalScroller(
at point: CGPoint,
in collectionView: UICollectionView
) -> Bool {
var candidate = collectionView.hitTest(point, with: nil)
while let view = candidate, view !== collectionView {
if let scrollView = view as? UIScrollView,
scrollView.alwaysBounceHorizontal
|| scrollView.contentSize.width > scrollView.bounds.width + 1 {
return true
}
candidate = view.superview
}
return false
}

func update(
threadID: String,
messages: [FeatureMessage],
Expand Down Expand Up @@ -1154,12 +1233,22 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
}

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
verticalDragStartOffset = scrollView.contentOffset.y
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let startOffset = verticalDragStartOffset,
abs(scrollView.contentOffset.y - startOffset) > 0.5 else {
return
}
verticalDragStartOffset = nil
(scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false
scrollView.window?.endEditing(false)
onDismissKeyboard?()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
verticalDragStartOffset = nil
guard !decelerate else { return }
updateBottomAnchor(for: scrollView)
}
Expand All @@ -1177,6 +1266,58 @@ private struct FeatureTranscriptCollectionView: UIViewRepresentable {
}
}

enum TranscriptTimestampRevealGeometry {
static let maximumWidth: CGFloat = 76
static let minimumHorizontalVelocity: CGFloat = 120
static let horizontalIntentRatio: CGFloat = 1.35

static func shouldBegin(velocityX: CGFloat, velocityY: CGFloat) -> Bool {
let horizontalSpeed = abs(velocityX)
return velocityX < 0
&& horizontalSpeed >= minimumHorizontalVelocity
&& horizontalSpeed >= abs(velocityY) * horizontalIntentRatio
}

static func width(translationX: CGFloat) -> CGFloat {
min(maximumWidth, max(0, -translationX))
}
}

@MainActor
private final class FeatureTimestampRevealState: ObservableObject {
@Published var width: CGFloat = 0
}

private struct FeatureTimestampRevealMessageView: View {
let message: FeatureMessage
@ObservedObject var reveal: FeatureTimestampRevealState

var body: some View {
ZStack(alignment: .topTrailing) {
FeatureMessageView(message: message)

if message.createdAt != .distantPast {
Text(message.createdAt, format: .dateTime.hour().minute())
.font(T3Typography.supporting.monospacedDigit())
.foregroundStyle(T3Colors.textTertiary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.dynamicTypeSize(.small ... .accessibility1)
.frame(
width: TranscriptTimestampRevealGeometry.maximumWidth,
alignment: .trailing
)
.padding(.vertical, 5)
.background(T3Colors.surface, in: Capsule())
.frame(width: reveal.width, alignment: .trailing)
.clipped()
.opacity(reveal.width > 0 ? 1 : 0)
.accessibilityHidden(true)
}
}
}
}

private struct FeatureLoadEarlierTurnsButton: View {
let isLoading: Bool
let onLoad: () -> Void
Expand Down Expand Up @@ -1532,6 +1673,7 @@ struct FeatureMessageView: View {
.accessibilityLabel("You")
.accessibilityValue(accessibilityValue)
.accessibilityIdentifier("message-\(message.id)")
.modifier(FeatureMessageTimestampAccessibilityModifier(message: message))
case .assistant:
VStack(alignment: .leading, spacing: 10) {
if message.state == .streaming {
Expand All @@ -1553,6 +1695,7 @@ struct FeatureMessageView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("message-\(message.id)")
.modifier(FeatureMessageTimestampAccessibilityModifier(message: message))
case .tool:
DisclosureGroup {
Text(message.text)
Expand All @@ -1569,12 +1712,14 @@ struct FeatureMessageView: View {
.padding(.vertical, 6)
.frame(minHeight: T3Metrics.minimumTapTarget)
.accessibilityIdentifier("message-\(message.id)")
.modifier(FeatureMessageTimestampAccessibilityModifier(message: message))
case .system:
Text(message.text)
.font(T3Typography.supporting)
.foregroundStyle(T3Colors.textSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.accessibilityIdentifier("message-\(message.id)")
.modifier(FeatureMessageTimestampAccessibilityModifier(message: message))
}
}

Expand All @@ -1589,6 +1734,31 @@ struct FeatureMessageView: View {
}
}

private struct FeatureMessageTimestampAccessibilityModifier: ViewModifier {
let message: FeatureMessage

@ViewBuilder
func body(content: Content) -> some View {
if message.createdAt == .distantPast {
content
} else {
content.accessibilityCustomContent(
Text(accessibilityLabel),
Text(message.createdAt.formatted(date: .omitted, time: .shortened)),
importance: .default
)
}
}

private var accessibilityLabel: String {
switch message.role {
case .user: "Sent"
case .assistant: "Received"
case .tool, .system: "Timestamp"
}
}
}

private struct FeatureMessageAttachmentsView: View {
let attachments: [FeatureMessageAttachment]
@State private var previewedAttachment: FeatureMessageAttachment?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import Testing
@testable import T3Code

@Suite("Transcript viewport anchoring")
@Suite("Transcript viewport and timestamp gestures")
struct TranscriptViewportGeometryTests {
@Test
func timestampRevealTracksLeftwardDragWithinBounds() {
#expect(TranscriptTimestampRevealGeometry.width(translationX: -32) == 32)
}

@Test
func timestampRevealClampsAtRestAndMaximumWidth() {
#expect(TranscriptTimestampRevealGeometry.width(translationX: 24) == 0)
#expect(
TranscriptTimestampRevealGeometry.width(translationX: -200)
== TranscriptTimestampRevealGeometry.maximumWidth
)
}

@Test
func timestampRevealClaimsOnlyDeliberateLeftwardHorizontalPans() {
#expect(
TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -240, velocityY: 40)
)
#expect(
!TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 240)
)
#expect(
!TranscriptTimestampRevealGeometry.shouldBegin(velocityX: 240, velocityY: 40)
)
#expect(
!TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -40, velocityY: 2)
)
#expect(
!TranscriptTimestampRevealGeometry.shouldBegin(velocityX: -120, velocityY: 100)
)
}

@Test
func firstLoadedTranscriptAnchorsToLatestMessage() {
let empty = TranscriptViewportGeometry(
Expand Down
6 changes: 6 additions & 0 deletions docs/user/swiftui-thread-transcript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Native iPhone thread transcript

In the native iPhone app, swipe left across the transcript to reveal the time on each message.
The gesture requires a deliberate horizontal swipe so ordinary vertical scrolling and text
selection keep their normal behavior. VoiceOver exposes the same time as custom content on each
message without requiring the gesture.
Loading