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

+57
View File
@@ -301,6 +301,9 @@ final class AppModel: ObservableObject {
}
func bootstrap() async {
#if DEBUG
if applyUITestChatIfRequested() { return }
#endif
guard !RuntimeEnvironment.isRunningTests else { return }
guard let saved = preferences.load() else { return }
prepareStore(for: saved.normalizedJID)
@@ -459,6 +462,55 @@ final class AppModel: ObservableObject {
}
}
#if DEBUG
/// UI-test mode: seeds an in-memory conversation with numbered messages
/// so XCUITests can exercise timeline scrolling and the reply swipe
/// without an XMPP server. Activated with the `-luma-ui-test-chat`
/// launch argument.
private func applyUITestChatIfRequested() -> Bool {
guard ProcessInfo.processInfo.arguments.contains("-luma-ui-test-chat") else {
return false
}
let account = AccountConfiguration(
jid: "uitest@example.org",
displayName: "UI Test",
resource: "uitest"
)
self.account = account
let conversation = Conversation(
jid: "peer@example.org",
displayName: "uitest-peer",
lastMessage: "Тестовое сообщение номер 60",
lastActivity: Date(),
kind: .direct
)
modelContext.insert(conversation)
conversations = [conversation]
let base = Date(timeIntervalSince1970: 1_700_000_000)
var seeded: [ChatMessage] = []
for index in 1...60 {
let outgoing = index.isMultiple(of: 2)
let message = ChatMessage(
id: "uitest-msg-\(index)",
conversationID: conversation.jid,
senderJID: outgoing ? account.normalizedJID : conversation.jid,
body: "Тестовое сообщение номер \(index)",
timestamp: base.addingTimeInterval(TimeInterval(index * 60)),
direction: outgoing ? .outgoing : .incoming,
delivery: .sent,
security: .plaintext,
kind: .text
)
modelContext.insert(message)
seeded.append(message)
}
messages = seeded
rebuildMessageIndex()
selectedConversationID = conversation.jid
return true
}
#endif
func setApplicationActive(_ active: Bool) {
appIsActive = active
guard !RuntimeEnvironment.isRunningTests else { return }
@@ -654,8 +706,13 @@ final class AppModel: ObservableObject {
}
func loadOlderHistoryForSelectedConversation() {
// While disconnected the request would only fail with
// "not connected", which used to pop the global alert right over
// the timeline and blocked scrolling. Skip silently instead: the
// trigger re-fires once the connection is back.
guard !isLoadingOlderHistory,
hasMoreOlderHistory,
connectionStatus == .connected,
let conversation = selectedConversation else { return }
// let oldestServerID = selectedMessages
// .sorted { lhs, rhs in
@@ -14,10 +14,13 @@ struct MessageReplySwipePolicy {
static let followDominance: CGFloat = 1.05
/// Whether this translation can lock the gesture as a horizontal swipe.
/// Only right-to-left (swipe left, negative width) is accepted; the
/// opposite direction is deliberately left to the scroll view.
/// Only right-to-left (swipe left, negative width) is accepted, and the
/// lock is only available early: once the finger has travelled mostly
/// vertically the scroll view owns the touch and the swipe must never
/// activate for the rest of that gesture.
static func canLock(_ translation: CGSize) -> Bool {
translation.width < 0
&& abs(translation.height) <= 24
&& isHorizontal(translation, dominance: lockDominance)
&& abs(translation.width) >= activationDistance * 0.5
}
+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