From d6dfaa8956b665b3491591ed0c65ed472ecb4c7c Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Sun, 18 Oct 2026 00:06:42 +0800 Subject: [PATCH] fix(chat): return the jump arrow to the message the user left, not the chat bottom --- lib/chat/chat_view.dart | 165 ++++++++++- test/chat_jump_origin_test.dart | 467 ++++++++++++++++++++++++++++++++ 2 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 test/chat_jump_origin_test.dart diff --git a/lib/chat/chat_view.dart b/lib/chat/chat_view.dart index 2c32b223..a243afdc 100644 --- a/lib/chat/chat_view.dart +++ b/lib/chat/chat_view.dart @@ -1137,6 +1137,23 @@ class _TranscriptEntry { ); } +/// Where the reader was standing when an in-transcript jump (a reply quote tap +/// or View in Chat) took them to another message. While this is live the +/// floating arrow returns here instead of the newest message. +class _ChatJumpOrigin { + const _ChatJumpOrigin({ + required this.messageId, + required this.viewportOffset, + }); + + /// Topmost fully visible row (or the closest partially visible one above it) + /// at the moment the jump was requested. + final int messageId; + + /// That row's top in viewport-local pixels, restored as-is on return. + final double viewportOffset; +} + class _ChatScrollSnapshot { const _ChatScrollSnapshot({ required this.pixels, @@ -1242,6 +1259,15 @@ class _ChatViewState extends State { Timer? _linkedMessageHighlightClearTimer; int? _scrollTargetId; int _scrollTargetGeneration = 0; + + /// See [_ChatJumpOrigin]. Live from the moment a reply/View in Chat jump + /// starts until the arrow consumes it, the user drags the transcript away + /// from the jump target, or the session ends. + _ChatJumpOrigin? _jumpOrigin; + + /// Scroll offset when the drag that may abandon [_jumpOrigin] began. Null + /// outside such a drag, so programmatic scrolls cannot measure against it. + double? _jumpOriginDragBaseline; int _transcriptGestureGeneration = 0; int? _lastNewestMessageId; int? _lastOldestMessageId; @@ -1558,6 +1584,12 @@ class _ChatViewState extends State { @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.chatId != widget.chatId || + oldWidget.forumTopicId != widget.forumTopicId) { + // A switch to another conversation or topic invalidates every recorded + // reading position, this one included. + _forgetJumpOrigin(); + } if (identical(oldWidget.exitController, widget.exitController)) return; _detachExitController?.call(); _detachExitController = widget.exitController?.register(_prepareExitState); @@ -1847,6 +1879,7 @@ class _ChatViewState extends State { _lastTranscriptUserScrollDirection = ScrollDirection.idle; final protectedRestoredPosition = _restoredPositionGuard .finishUserScroll(); + _releaseJumpOriginAfterDrag(); _returnToLatestCoordinator.userDragEnded(); if (endedTowardLatest && !protectedRestoredPosition) { unawaited(_continueHistoryIfNearLatest()); @@ -1863,6 +1896,9 @@ class _ChatViewState extends State { _lastTranscriptUserScrollDirection = notification.direction; _restoredPositionGuard.noteUserScroll(); _claimTranscriptViewport(); + // A real drag is the only thing that can carry the reader away from the + // jump target; measure how far it went when it ends. + _noteJumpOriginDragStart(); } return false; } @@ -2080,6 +2116,7 @@ class _ChatViewState extends State { void _prepareExitState() { if (_exitStatePrepared) return; _exitStatePrepared = true; + _forgetJumpOrigin(); final sessionReopenPending = _sessionReopenPending; _cancelSessionReopenNavigation(); if (!sessionReopenPending && !_maintainSessionScrollAnchor) { @@ -2489,7 +2526,66 @@ class _ChatViewState extends State { bool get _showReturnToLatestProgress => _returnToLatestCoordinator.showProgress; + /// Records where the reader is standing before an in-transcript jump. A + /// chain of jumps keeps the FIRST origin: that is the place the reader + /// actually left, and every later jump is only a detour away from it. + void _captureJumpOrigin() { + if (_jumpOrigin != null || _scrollTargetId != null) return; + if (!mounted || !_initialTranscriptReady || !_scroll.hasClients) return; + // Already at the newest edge: the arrow's existing meaning is still the + // right one, so there is nothing to remember. + if (_isAtLoadedBottom(80)) return; + final anchor = _captureSessionScrollAnchor(); + if (anchor == null) return; + _jumpOrigin = _ChatJumpOrigin( + messageId: anchor.messageId, + viewportOffset: anchor.viewportOffset, + ); + } + + void _forgetJumpOrigin() { + _jumpOrigin = null; + _jumpOriginDragBaseline = null; + } + + /// Remembers where the viewport stood when a real drag began. A tap (reply + /// quote, timestamp) produces no scroll notification, so it can never be + /// mistaken for the drag that abandons the origin. + void _noteJumpOriginDragStart() { + if (_jumpOrigin == null || !_hasTranscriptPointerDown) return; + _jumpOriginDragBaseline ??= _scroll.hasClients + ? _scroll.position.pixels + : null; + } + + /// Drops the origin once a drag has carried the viewport a meaningful + /// distance away from the jump target. A small nudge to read the quoted + /// neighbourhood keeps the arrow pointing back at where the reader left. + void _releaseJumpOriginAfterDrag() { + final baseline = _jumpOriginDragBaseline; + _jumpOriginDragBaseline = null; + if (baseline == null || _jumpOrigin == null || !_scroll.hasClients) return; + final viewport = _transcriptViewportKey.currentContext?.findRenderObject(); + final viewportHeight = viewport is RenderBox && viewport.attached + ? viewport.size.height + : 0.0; + final threshold = math.max(120.0, viewportHeight * 0.35); + if ((_scroll.position.pixels - baseline).abs() >= threshold) { + _forgetJumpOrigin(); + } + } + + /// The floating arrow: returns to the message the reader left while a jump + /// origin is live, and to the newest message otherwise. void _onReturnToLatestTapped() { + final origin = _jumpOrigin; + if (origin != null) { + // A tap always consumes the origin, even when its row can no longer be + // reached: a stale origin must never outlive the tap that asked for it. + _jumpOrigin = null; + unawaited(_returnToJumpOrigin(origin)); + return; + } _requestReturnToLatest(userInitiated: true); } @@ -2526,9 +2622,52 @@ class _ChatViewState extends State { _markReadAtBottomIfNeeded(); } + Future _returnToJumpOrigin(_ChatJumpOrigin origin) async { + final restored = await _restoreJumpOrigin(origin); + // The origin's row can vanish between the tap and the layout (a paged + // window replaced under it); keep the arrow's old contract as the fallback + // rather than swallowing the tap. + if (!restored && mounted) _requestReturnToLatest(userInitiated: true); + } + + Future _restoreJumpOrigin(_ChatJumpOrigin origin) async { + if (!mounted || !_scroll.hasClients) return false; + if (!_vm.messages.any((message) => message.id == origin.messageId)) { + return false; + } + _cancelSessionScrollAnchorMaintenance(); + _maintainRestoredBottom = false; + _restoredPositionGuard.cancel(); + _cancelBottomFollow(); + _stopActiveTranscriptScroll(); + // A re-pivot makes raw pixels meaningless, so approach the origin with the + // alignment fraction it had and then correct by measurement. + final viewport = _transcriptViewportKey.currentContext?.findRenderObject(); + final viewportHeight = viewport is RenderBox && viewport.attached + ? viewport.size.height + : null; + final alignment = viewportHeight == null || viewportHeight <= 0 + ? 0.0 + : (origin.viewportOffset / viewportHeight).clamp(0.0, 1.0); + final reached = await _scrollToMessageAndReport( + origin.messageId, + alignment: alignment, + forceAlignment: true, + ); + if (!reached || !mounted) return false; + _restoreMessageViewportOffset(origin.messageId, origin.viewportOffset); + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return false; + _restoreMessageViewportOffset(origin.messageId, origin.viewportOffset); + _saveSessionScrollSnapshot(); + return true; + } + void _requestReturnToLatest({bool userInitiated = false}) { if (!userInitiated && _hasTranscriptPointerDown) return; if (userInitiated) { + // Returning to the newest message abandons the jump origin by design. + _forgetJumpOrigin(); _transcriptViewportClaimedByUser = false; _cancelSessionReopenNavigation(userClaimedViewport: true); _restoredPositionGuard.cancel(); @@ -2673,6 +2812,8 @@ class _ChatViewState extends State { /// boundary may already point at the newest message after the chat is marked /// read, so it cannot be used to resolve this button later. Future _jumpToFirstUnread() async { + // Choosing a different destination drops the recorded jump origin. + _forgetJumpOrigin(); _claimTranscriptViewport(); await _jumpToFirstUnreadImpl(); } @@ -3332,6 +3473,9 @@ class _ChatViewState extends State { /// deterministic estimate immediately and then do one zero-duration correction /// after layout, without waiting to reveal the UI. Future _completeInitialScroll() async { + // Opening (or reopening) the session establishes a fresh reading position; + // an origin from a previous visit of this state must not survive it. + _forgetJumpOrigin(); if (_shouldRestoreSessionScroll) { await _restoreSessionScrollPosition(); } else { @@ -3474,9 +3618,15 @@ class _ChatViewState extends State { bool _restoreSessionScrollAnchor(_ChatScrollSnapshot snapshot) { final messageId = snapshot.anchorMessageId; final desiredOffset = snapshot.anchorViewportOffset; - if (messageId == null || desiredOffset == null || !_scroll.hasClients) { - return false; - } + if (messageId == null || desiredOffset == null) return false; + return _restoreMessageViewportOffset(messageId, desiredOffset); + } + + /// Puts [messageId] back at [desiredOffset] pixels below the transcript + /// viewport top, measured rather than replayed as raw pixels: the row may sit + /// in a different sliver window than it did when the offset was recorded. + bool _restoreMessageViewportOffset(int messageId, double desiredOffset) { + if (!_scroll.hasClients) return false; final viewportContext = _transcriptViewportKey.currentContext; final itemContext = _entryVisibilityKeys[messageId]?.currentContext; final viewportRenderObject = viewportContext?.findRenderObject(); @@ -5079,6 +5229,7 @@ class _ChatViewState extends State { Future _jumpToMessage(int messageId, {bool reply = false}) async { if (!mounted) return; + _captureJumpOrigin(); setState(() => _setScrollTarget(messageId, forceNavigation: true)); if (!reply) return; final target = _vm.messages.where((m) => m.id == messageId).firstOrNull; @@ -5187,6 +5338,7 @@ class _ChatViewState extends State { MessageRepliesViewTarget target, ) async { if (target.chatId == widget.chatId) { + _captureJumpOrigin(); await _scrollToMessage(target.messageId); return; } @@ -7236,7 +7388,9 @@ class _ChatViewState extends State { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: pointsDown - ? _onReturnToLatestTapped + // The unread pill always means "go to the newest message", even + // while the arrow would return to a jump origin. + ? () => _requestReturnToLatest(userInitiated: true) : () => unawaited(_jumpToFirstUnread()), child: Container( padding: EdgeInsetsDirectional.fromSTEB( @@ -8940,6 +9094,9 @@ class _ChatViewState extends State { } Future _openReplyMessage(int messageId) async { + // Capture before the viewport is claimed: the claim marks a navigation in + // progress and the recorded origin is the frame the reader just left. + _captureJumpOrigin(); _claimTranscriptViewport(); final didReachTarget = await _scrollToMessageAndReport(messageId); if (!didReachTarget || !mounted) return; diff --git a/test/chat_jump_origin_test.dart b/test/chat_jump_origin_test.dart new file mode 100644 index 00000000..452e8ff4 --- /dev/null +++ b/test/chat_jump_origin_test.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mithka/chat/chat_view.dart' + show ChatView, clearChatMemoryCaches; +import 'package:mithka/l10n/app_localizations.dart'; +import 'package:mithka/settings/translation_controller.dart'; +import 'package:mithka/tdlib/td_client.dart'; +import 'package:mithka/theme/app_theme.dart'; +import 'package:mithka/theme/theme_controller.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'support/l10n_fixtures.dart'; + +// The floating arrow at the transcript's bottom-right corner normally returns +// to the newest message. After a reply-quote (or View in Chat) jump it must +// instead return to the message the reader was looking at before that jump — +// and only while that origin is still meaningful. + +const _chatId = -1000000007777; +const _messageCount = 40; + +/// TDLib message ids are packed: `server_id << 20`. +int _mid(int serverId) => serverId << 20; + +const _replyId = 36; // replies to 5 +const _parentId = 5; // replies to 2 +const _grandparentId = 2; + +Map _message(int serverId, {int? replyTo}) => { + '@type': 'message', + 'id': _mid(serverId), + 'chat_id': _chatId, + 'date': 1785862260 + serverId, + 'is_outgoing': false, + 'sender_id': {'@type': 'messageSenderUser', 'user_id': 2}, + 'content': { + '@type': 'messageText', + 'text': { + '@type': 'formattedText', + 'text': 'Message $serverId\nsecond line\nthird line\nfourth line', + 'entities': >[], + }, + }, + if (replyTo != null) + 'reply_to': { + '@type': 'messageReplyToMessage', + 'chat_id': _chatId, + 'message_id': _mid(replyTo), + 'quote': { + '@type': 'textQuote', + 'text': { + '@type': 'formattedText', + 'text': 'Quoted message $replyTo', + 'entities': >[], + }, + 'position': 0, + 'is_manual': true, + }, + }, +}; + +/// Newest → oldest, the way TDLib answers `getChatHistory`. +List> _history() => [ + for (var serverId = _messageCount; serverId >= 1; serverId--) + _message( + serverId, + replyTo: serverId == _replyId + ? _parentId + : serverId == _parentId + ? _grandparentId + : null, + ), +]; + +Map _chatJson() => { + '@type': 'chat', + 'id': _chatId, + 'title': 'Jump origin group', + 'type': { + '@type': 'chatTypeSupergroup', + 'supergroup_id': -_chatId - 1000000000000, + 'is_channel': false, + }, + 'last_read_inbox_message_id': 0, + 'unread_count': 0, + 'permissions': {'can_send_basic_messages': true}, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + L10nFixtures.load().install(); + + late List> requests; + + setUpAll(() { + TdClient.shared.configureProxy( + TdClientProxyTransport( + accountSlot: 0, + query: (request) async { + requests.add(Map.from(request)); + switch (request['@type']) { + case 'getChat': + return _chatJson(); + case 'getChatHistory': + final offset = request['offset'] as int? ?? 0; + // Real TDLib: "Parameter offset must be non-positive". + if (offset > 0) { + throw StateError('Parameter offset must be non-positive'); + } + final history = _history(); + final from = request['from_message_id'] as int? ?? 0; + final limit = request['limit'] as int? ?? 40; + var start = 0; + if (from > 0) { + final index = history.indexWhere( + (m) => (m['id'] as int) <= from, + ); + start = index < 0 + ? history.length + : index + (offset < 0 ? -offset - 1 : 0); + } + if (start >= history.length) { + return { + '@type': 'messages', + 'total_count': history.length, + 'messages': >[], + }; + } + final page = history.skip(start).take(limit).toList(); + return { + '@type': 'messages', + 'total_count': page.length, + 'messages': page, + }; + case 'getMessages': + final ids = (request['message_ids'] as List?)?.cast() ?? []; + final byId = {for (final m in _history()) m['id'] as int: m}; + return { + '@type': 'messages', + 'total_count': ids.length, + 'messages': [ + for (final id in ids) + if (byId[id] != null) byId[id]!, + ], + }; + case 'getMe': + return { + '@type': 'user', + 'id': 1, + 'first_name': 'Reader', + 'type': {'@type': 'userTypeRegular'}, + }; + case 'getUser': + return { + '@type': 'user', + 'id': request['user_id'] ?? 2, + 'first_name': 'Member', + 'type': {'@type': 'userTypeRegular'}, + }; + case 'getSupergroup': + return {'@type': 'supergroup', 'id': -_chatId - 1000000000000}; + case 'getSupergroupFullInfo': + return {'@type': 'supergroupFullInfo', 'member_count': 12}; + case 'getMessageAvailableReactions': + return { + '@type': 'availableReactions', + 'top_reactions': >[], + 'recent_reactions': >[], + 'popular_reactions': >[], + 'allow_custom_emoji': false, + 'are_tags': false, + 'unavailability_reason': null, + }; + case 'getOption': + return {'@type': 'optionValueBoolean', 'value': false}; + default: + return {'@type': 'ok'}; + } + }, + send: (_) async {}, + updates: const Stream.empty(), + ), + ); + }); + tearDownAll(TdClient.shared.closeProxy); + + late ThemeController theme; + late TranslationController translation; + + setUp(() async { + requests = []; + SharedPreferences.setMockInitialValues({'openChatsAtLatest': true}); + theme = ThemeController(await SharedPreferences.getInstance()); + translation = TranslationController(await SharedPreferences.getInstance()); + }); + tearDown(() { + clearChatMemoryCaches(); + theme.dispose(); + translation.dispose(); + }); + + Widget app() => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value(value: translation), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [AppColors.light], + platform: TargetPlatform.android, + ), + locale: const Locale('en'), + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: const ChatView(chatId: _chatId, title: 'Jump origin group'), + ), + ); + + Finder transcript() => find.byType(CustomScrollView); + + ScrollPosition position(WidgetTester tester) => tester + .state( + find.descendant(of: transcript(), matching: find.byType(Scrollable)), + ) + .position; + + Future pumpFrames(WidgetTester tester, {int frames = 30}) async { + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 40)); + } + } + + Future pumpChat(WidgetTester tester) async { + tester.view.physicalSize = const Size(420, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + clearChatMemoryCaches(); + await tester.pumpWidget(app()); + // The transcript keeps timers alive (progress polling), so bounded pumps + // instead of pumpAndSettle; the entry positioning runs several endOfFrame + // passes before it reveals the chat. + await pumpFrames(tester, frames: 14); + expect( + find.byKey(ValueKey('messageTapTarget-${_mid(_messageCount)}')), + findsOneWidget, + reason: 'the newest message must render', + ); + } + + Finder messageRow(int serverId) => + find.byKey(ValueKey('messageTapTarget-${_mid(serverId)}')); + + Finder replyQuoteOf(int serverId) => find.descendant( + of: messageRow(serverId), + matching: find.byKey(const ValueKey('messageReplyOpenOriginal')), + ); + + final jumpArrow = find.byKey(const ValueKey('chat-jump-to-bottom')); + + /// Rows currently intersecting the transcript viewport, mapped to their top + /// offset inside that viewport. + Map visibleRows(WidgetTester tester) { + final viewport = tester.getRect(transcript()); + final rows = {}; + final finder = find.byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && + key.value.startsWith('messageTapTarget-'); + }); + for (final element in finder.evaluate()) { + final key = element.widget.key! as ValueKey; + final id = int.tryParse(key.value.substring('messageTapTarget-'.length)); + final box = element.renderObject; + if (id == null || box is! RenderBox || !box.attached) continue; + final top = box.localToGlobal(Offset.zero).dy; + final bottom = top + box.size.height; + if (bottom <= viewport.top || top >= viewport.bottom) continue; + rows[id] = top - viewport.top; + } + return rows; + } + + Future dragTranscript(WidgetTester tester, double distance) async { + final steps = (distance.abs() / 45).ceil().clamp(1, 1000); + final gesture = await tester.startGesture(tester.getCenter(transcript())); + for (var i = 0; i < steps; i++) { + await gesture.moveBy(Offset(0, distance / steps)); + await tester.pump(); + } + await gesture.up(); + await pumpFrames(tester, frames: 8); + } + + /// Leaves the loaded bottom by dragging toward older messages, so a reply + /// jump starts from somewhere the reader chose to be. Stepped rather than a + /// single `drag`: the transcript measures the latest row during layout, so a + /// one-shot fling would be judged against a frame-old measurement. + Future dragUpFromBottom(WidgetTester tester, double distance) async { + expect( + jumpArrow, + findsNothing, + reason: 'the newest message is on screen before the drag', + ); + await dragTranscript(tester, distance); + expect( + jumpArrow, + findsOneWidget, + reason: 'scrolling up reveals the floating arrow', + ); + } + + testWidgets( + 'the arrow returns to the message the reader left, not the bottom', + (tester) async { + await pumpChat(tester); + await dragUpFromBottom(tester, 320); + + final before = visibleRows(tester); + expect(before, contains(_mid(_replyId))); + final replyTop = before[_mid(_replyId)]!; + + await tester.tap(replyQuoteOf(_replyId)); + await pumpFrames(tester); + + // The jump landed on the quoted parent, far above the reply. + expect( + visibleRows(tester), + contains(_mid(_parentId)), + reason: 'tapping the quote jumps to the replied-to message', + ); + expect(visibleRows(tester), isNot(contains(_mid(_replyId)))); + expect(jumpArrow, findsOneWidget); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + + final after = visibleRows(tester); + expect( + after, + contains(_mid(_replyId)), + reason: + 'the arrow restores the message that was in view before the jump', + ); + expect( + after[_mid(_replyId)], + closeTo(replyTop, 1.5), + reason: 'the origin row comes back at its recorded viewport offset', + ); + // Every row that was on screen returns to the same place. + for (final entry in before.entries) { + if (!after.containsKey(entry.key)) continue; + expect(after[entry.key], closeTo(entry.value, 1.5)); + } + // And the viewport did not fall through to the chat bottom. + final pos = position(tester); + expect(pos.pixels, lessThan(pos.maxScrollExtent - 200)); + expect(jumpArrow, findsOneWidget); + }, + ); + + testWidgets('without a preceding jump the arrow still goes to the bottom', ( + tester, + ) async { + await pumpChat(tester); + await dragUpFromBottom(tester, 320); + expect(visibleRows(tester), isNot(contains(_mid(_messageCount)))); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + + expect( + visibleRows(tester), + contains(_mid(_messageCount)), + reason: 'the arrow keeps its original meaning with no stored origin', + ); + expect( + jumpArrow, + findsNothing, + reason: 'the newest message is on screen again', + ); + }); + + testWidgets('a chain of jumps returns to the first origin', (tester) async { + await pumpChat(tester); + await dragUpFromBottom(tester, 320); + final before = visibleRows(tester); + final replyTop = before[_mid(_replyId)]!; + + await tester.tap(replyQuoteOf(_replyId)); + await pumpFrames(tester); + expect(visibleRows(tester), contains(_mid(_parentId))); + + // Second jump: the parent carries its own quote. + await tester.tap(replyQuoteOf(_parentId)); + await pumpFrames(tester); + expect(visibleRows(tester), contains(_mid(_grandparentId))); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + + final after = visibleRows(tester); + expect( + after, + contains(_mid(_replyId)), + reason: 'the chain returns to where the reader first left', + ); + expect(after[_mid(_replyId)], closeTo(replyTop, 1.5)); + expect(after, isNot(contains(_mid(_parentId)))); + }); + + testWidgets('a second tap after the origin is consumed goes to the bottom', ( + tester, + ) async { + await pumpChat(tester); + await dragUpFromBottom(tester, 320); + await tester.tap(replyQuoteOf(_replyId)); + await pumpFrames(tester); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + expect(visibleRows(tester), contains(_mid(_replyId))); + expect(jumpArrow, findsOneWidget); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + expect( + visibleRows(tester), + contains(_mid(_messageCount)), + reason: 'the consumed origin must not survive a second tap', + ); + expect(jumpArrow, findsNothing); + }); + + testWidgets('a long user drag away from the jump target drops the origin', ( + tester, + ) async { + await pumpChat(tester); + await dragUpFromBottom(tester, 320); + await tester.tap(replyQuoteOf(_replyId)); + await pumpFrames(tester); + expect(visibleRows(tester), contains(_mid(_parentId))); + + // Read a long way away from where the jump landed, back toward the + // newest messages: the quoted parent must leave the viewport. + await dragTranscript(tester, -520); + expect(visibleRows(tester), isNot(contains(_mid(_parentId)))); + + await tester.tap(jumpArrow); + await pumpFrames(tester); + expect( + visibleRows(tester), + contains(_mid(_messageCount)), + reason: 'an abandoned origin must not teleport the reader back', + ); + }); + + test('the history fixture keeps TDLib history semantics', () { + final ids = _history().map((m) => m['id'] as int).toList(); + final descending = [...ids]..sort((a, b) => b - a); + expect( + ids, + orderedEquals(descending), + reason: 'pages must arrive newest → oldest', + ); + }); +}