stop the disconnected-history alert from blocking scrolling
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- Skip older-history loads silently while the connection is down:
  the trigger previously failed with "not connected", popped the
  global alert over the timeline and froze scrolling until dismissed.
- Accept fast reply-swipe flicks whose end state is clearly leftward
  even without an intermediate horizontal lock, while scroll-owned
  touches still never activate the swipe.
- Add a seeded-chat UI test mode (launch argument), accessibility
  identifiers and a LumaUITests target with timeline scroll and reply
  swipe tests; guard the fix in verify.sh.
This commit is contained in:
wt committed 2026-08-30 00:09:05 +07:00
1 parent 2868a13abd
commit 5a7fc36722
10 files changed
+304 -12

No files matched your search

+2
View File
@@ -628,6 +628,7 @@ struct ChatView: View {
.onChange(of: messages) { _, newMessages in
rebuildTimelineEntries(from: newMessages)
}
.accessibilityIdentifier("chat-timeline")
#if os(macOS)
.onPreferenceChange(TimelineBottomYPreferenceKey.self) {
bottomY in
@@ -1851,6 +1852,7 @@ struct ChatView: View {
.buttonStyle(.plain)
.accessibilityLabel("Отменить ответ")
}
.accessibilityIdentifier("reply-banner")
// .padding(.horizontal, 12)
// .padding(.vertical, 8)
// .background(
@@ -28,6 +28,7 @@ struct MessageBubble: View {
@State private var replySwipeOffset: CGFloat = 0
@State private var replySwipeArmed = false
@State private var replySwipeLocked = false
@State private var scrollOwnsGesture = false
var body: some View {
ZStack {
@@ -38,6 +39,7 @@ struct MessageBubble: View {
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.contentShape(Rectangle())
.accessibilityIdentifier("bubble-\(message.clientID)")
.onTapGesture {
guard isSelectionMode else { return }
onToggleSelection()
@@ -55,6 +57,7 @@ struct MessageBubble: View {
replySwipeOffset = 0
replySwipeArmed = false
replySwipeLocked = false
scrollOwnsGesture = false
}
.contextMenu {
if !isSelectionMode {
@@ -178,12 +181,20 @@ struct MessageBubble: View {
private var replySwipeGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
// Once the gesture is clearly horizontal it locks in, so a
// slightly diagonal finish can neither break the reply swipe
// nor let the scroll view steal it.
if !replySwipeLocked, MessageReplySwipePolicy.canLock(value.translation) {
replySwipeLocked = true
// Arbitration: once the touch is clearly vertical the scroll
// view owns it for good, so a later horizontal arc can never
// move bubbles mid-scroll. A reply swipe locks in only while
// the finger is still moving mostly horizontally.
if !replySwipeLocked, !scrollOwnsGesture {
if MessageReplySwipePolicy.canLock(value.translation) {
replySwipeLocked = true
} else if abs(value.translation.height) > 16,
abs(value.translation.width) < abs(value.translation.height)
{
scrollOwnsGesture = true
}
}
guard replySwipeLocked, !scrollOwnsGesture else { return }
let offset = MessageReplySwipePolicy.offset(
locked: replySwipeLocked,
translation: value.translation
@@ -212,12 +223,19 @@ struct MessageBubble: View {
replySwipeArmed = armed
}
.onEnded { value in
let shouldReply = MessageReplySwipePolicy.shouldReply(
locked: replySwipeLocked,
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation
)
// The lock is a hint, not a requirement: a fast flick may
// deliver no intermediate frame that passes canLock, yet its
// end state is still a clear leftward swipe. scrollOwnsGesture
// alone protects the timeline from reply activation.
let shouldReply =
!scrollOwnsGesture
&& MessageReplySwipePolicy.shouldReply(
locked: replySwipeLocked,
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation
)
replySwipeLocked = false
scrollOwnsGesture = false
if replySwipeOffset != 0 || replySwipeArmed {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
replySwipeOffset = 0