diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index b85a06c840e..cf3f5446b29 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -33,6 +33,7 @@ class InitialThreadTailSettle { required BuildContext context, required ItemScrollController controller, required ItemPositionsListener positionsListener, + required ScrollPosition? Function() activePosition, required int? targetIndex, required double hiddenTopFraction, required double hiddenBottomFraction, @@ -68,6 +69,9 @@ class InitialThreadTailSettle { // that fully visible target down would only add empty space above the // head. A clipped tail still takes the measured correction path. if (targetIsFullyVisible) { + // A superseded generation's placement may have left the position + // overscrolled; never reveal a viewport that is still springing. + settleThreadScrollPositionInRange(activePosition()); _isComplete = true; onSettled(); return; @@ -83,9 +87,24 @@ class InitialThreadTailSettle { duration: const Duration(milliseconds: 1), ) .whenComplete(() { - if (generation != _generation) return; - _isComplete = true; - onSettled(); + // The package animates to an unclamped offset. When the target + // (plus its trailing padding) is shorter than the visible area, + // that offset lies past maxScrollExtent; iOS bouncing physics + // then lets the 1 ms drive overshoot and springs the whole + // thread back over ~600 ms. Settle the active position inside + // its range after the placement has laid out, and only then + // reveal, so the viewport first paints at rest on the tail. + // The clamp runs even for a superseded generation: it is + // idempotent, and the newer generation must not inherit a + // spring it cannot see. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + settleThreadScrollPositionInRange(activePosition()); + if (generation != _generation) return; + _isComplete = true; + onSettled(); + }); + WidgetsBinding.instance.scheduleFrame(); }); }); // A post-frame callback does not itself request the frame in which it @@ -95,3 +114,19 @@ class InitialThreadTailSettle { }); } } + +/// Moves an overscrolled thread position back inside its scroll range. +/// +/// Programmatic placements can leave the position beyond its extents on iOS, +/// where bouncing physics does not clamp driven scrolls; the resulting spring +/// is the visible entry bounce. A clamped jump ends the ballistic activity and +/// leaves the position idle. Returns whether a correction was applied. +@visibleForTesting +bool settleThreadScrollPositionInRange(ScrollPosition? position) { + if (position == null || !position.hasContentDimensions) return false; + if (!position.outOfRange) return false; + position.jumpTo( + position.pixels.clamp(position.minScrollExtent, position.maxScrollExtent), + ); + return true; +} diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 7ff39fac531..13d25b8efd1 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -16,6 +16,12 @@ Widget _trackActiveThreadScrollPosition( }, ); +/// Whether [position] is moving under a scroll activity no finger started. +bool _threadScrollIsProgrammaticallyMoving( + ScrollPosition position, { + required bool isDragging, +}) => !isDragging && position.isScrollingNotifier.value; + bool _jumpActiveThreadScrollToTail( ObjectRef activePosition, bool Function()? testOverride, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 154193b3d2f..ac69071d68f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -259,6 +259,8 @@ class ThreadDetailPage extends HookConsumerWidget { final hidesLatestForComposerTailCorrection = useState(false); final tailCorrectionInProgress = useRef(false); final tailCorrectionGeneration = useRef(0); + final initialSettleRevealGeneration = useRef(0); + final idleTailRecheck = useRef<(ScrollPosition, VoidCallback)?>(null); final activeThreadScrollPosition = useRef(null); final composerHasFocus = useListenable(composerFocusNode).hasFocus; final viewportHeight = useListenable(listViewport.height).value; @@ -280,6 +282,18 @@ class ThreadDetailPage extends HookConsumerWidget { // the hydrated target, then reveal the settled viewport. final threadViewportVisible = !relayRepliesAvailable || initialViewportReady.value; + // A deep link renders its route snapshot while the relay query is still + // in flight, then closes the viewport gate to place the hydrated target. + // Latest measured against that provisional snapshot would mount, unmount + // for the placement frames, and mount again; wait for the placement. + // Only the first attempt counts: a failed query's retry window carries + // its error while loading, and a reader browsing the snapshot through + // that window keeps their way back to the tail. + final hidesLatestForDeepLinkSnapshot = + initialMessageId != null && + !relayRepliesAvailable && + relayReplyState.isLoading && + !relayReplyState.hasError; // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; @@ -410,10 +424,76 @@ class ThreadDetailPage extends HookConsumerWidget { WidgetsBinding.instance.scheduleFrame(); } + void cancelIdleTailRecheck() { + final pending = idleTailRecheck.value; + if (pending == null) return; + pending.$1.isScrollingNotifier.removeListener(pending.$2); + idleTailRecheck.value = null; + } + + // Re-decide the tail once a programmatic scroll on [position] goes idle. + // The final tick can land without a further position report, so the + // hysteresis in onPositionsChanged needs this to release its hold. + void scheduleIdleTailRecheck(ScrollPosition position) { + if (identical(idleTailRecheck.value?.$1, position)) return; + cancelIdleTailRecheck(); + void onScrollingChanged() { + if (position.isScrollingNotifier.value) return; + cancelIdleTailRecheck(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || tailCorrectionInProgress.value) return; + // Back-to-back programmatic activities (a correction chained onto + // a spring) pass through idle for a microtask; keep holding. + if (_threadScrollIsProgrammaticallyMoving( + position, + isDragging: tailIntent.isDragging, + )) { + scheduleIdleTailRecheck(position); + return; + } + final tailIsVisible = threadTailIsVisible(); + if (isAtThreadTail.value != tailIsVisible) { + isAtThreadTail.value = tailIsVisible; + } + }); + WidgetsBinding.instance.scheduleFrame(); + } + + idleTailRecheck.value = (position, onScrollingChanged); + position.isScrollingNotifier.addListener(onScrollingChanged); + } + + // Ordinary entry keeps Latest hidden until the initial settle has placed + // the tail. Positions refresh one frame after that placement, so decide + // from a fenced post-frame callback; a drag that begins first owns the + // flag instead (onUserScrollStart bumps the generation). Without this + // release a tail that ends below the composer had no way back. + void revealLatestAfterInitialSettle() { + final generation = ++initialSettleRevealGeneration.value; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || + generation != initialSettleRevealGeneration.value) { + return; + } + if (!tailCorrectionInProgress.value) { + isAtThreadTail.value = threadTailIsVisible(); + } + hidesLatestForInitialTailSettle.value = false; + }); + WidgetsBinding.instance.scheduleFrame(); + } + + useEffect(() => cancelIdleTailRecheck, const []); + void followThreadTailFromComposer() { if (userDragDetachedTailFollow.value) return; hidesLatestForComposerTailCorrection.value = true; + // Abandoning the settle here means its reveal never runs; release the + // entry gate now so a tail left below the fold still offers Latest + // once the composer blurs. initialTailSettle.abandon(); + initialSettleRevealGeneration.value++; + hidesLatestForInitialTailSettle.value = false; initialViewportReady.value = true; tailIntent.endDrag(); tailIntent.detach(); @@ -444,9 +524,24 @@ class ThreadDetailPage extends HookConsumerWidget { followsThreadTail.value = true; } if (tailCorrectionInProgress.value) return; - if (isAtThreadTail.value != tailIsVisible) { - isAtThreadTail.value = tailIsVisible; + if (isAtThreadTail.value == tailIsVisible) return; + // A driven or ballistic scroll that no finger started (a placement, + // an iOS rubber-band, a correction) reports intermediate positions + // every frame. Those may hide Latest early but must not reveal it: + // the motion is heading for the tail, and mounting the native + // control per report is the visible strobe. The user's own drags + // and the idle position after the motion ends decide freely. + final position = activeThreadScrollPosition.value; + if (!tailIsVisible && + position != null && + _threadScrollIsProgrammaticallyMoving( + position, + isDragging: tailIntent.isDragging, + )) { + scheduleIdleTailRecheck(position); + return; } + isAtThreadTail.value = tailIsVisible; } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -459,6 +554,7 @@ class ThreadDetailPage extends HookConsumerWidget { replies.length, liveHead.createdAt, viewportHeight, + timelineBottomInset, ], ); @@ -577,6 +673,10 @@ class ThreadDetailPage extends HookConsumerWidget { initialTargetReadyForHighlight.value = true; initialViewportReady.value = true; }); + // A post-frame callback does not request its own frame; a slow + // relay can otherwise park the hydrated viewport until an + // unrelated redraw. + WidgetsBinding.instance.scheduleFrame(); } itemPositionsListener.itemPositions.addListener( @@ -620,6 +720,7 @@ class ThreadDetailPage extends HookConsumerWidget { context: context, controller: itemScrollController, positionsListener: itemPositionsListener, + activePosition: () => activeThreadScrollPosition.value, targetIndex: replies.isEmpty ? null : indexForReply(replies.length - 1), @@ -627,7 +728,9 @@ class ThreadDetailPage extends HookConsumerWidget { hiddenBottomFraction: (composerDockHeight.value + settledImeLift) / viewportHeight, onSettled: () { - if (context.mounted) initialViewportReady.value = true; + if (!context.mounted) return; + initialViewportReady.value = true; + revealLatestAfterInitialSettle(); }, ); return null; @@ -855,6 +958,7 @@ class ThreadDetailPage extends HookConsumerWidget { child: _ThreadMessageList( viewport: listViewport, onUserScrollStart: () { + initialSettleRevealGeneration.value++; hidesLatestForInitialTailSettle.value = false; hidesLatestForComposerTailCorrection.value = false; initialTailSettle.abandon(); @@ -974,6 +1078,7 @@ class ThreadDetailPage extends HookConsumerWidget { visible: threadViewportVisible && hasFetchedReplies && + !hidesLatestForDeepLinkSnapshot && !isNavigatingToThreadTail.value && !hidesLatestForInitialTailSettle.value && !hidesLatestForComposerTailCorrection.value && diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 23f547dbad0..09f6de55a85 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -30,6 +30,7 @@ import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; +import 'package:buzz/features/channels/initial_thread_tail_settle.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/message_actions.dart'; @@ -109,6 +110,84 @@ NostrEvent _textMsg({ sig: '', ); +// A user-reported thread shape: a short head, two long multi-paragraph CJK +// agent replies, and a short human reply between them. At a phone-sized +// viewport the last reply plus its trailing padding is shorter than the area +// below the app bar, which is the geometry that made the initial tail +// placement overshoot the scroll extent on iOS. +const _aigeCharlie = 'charlie'; +const _aigeBot = 'aige-bot'; +const _aigeRootId = 'aige-thread-root'; +const _aigeUsers = { + _aigeCharlie: UserProfile(pubkey: _aigeCharlie, displayName: 'Charlie'), + _aigeBot: UserProfile(pubkey: _aigeBot, displayName: 'AIGE QA'), +}; +const _aigeLongReplyOne = + '@Charlie 有,但只在你已经授权的范围内。我不会自己给自己开一摊新活。\n\n' + '活到我手里之后,拆任务、派谁、自己干还是等人交卷、卡住怎么绕,这些我自己排。' + '你说过要快就自己干、别双开,我就按这个来。\n\n' + '起不了步的是这几件事:没人点名、也没有新证据,我不会自己找事做;飞书往外发、' + '提 Meego、改生产,没有你点头我不做;也不会自己循环刷状态。我这边是被 @ 才醒,' + '没有你设的定时,我醒不过来。\n\n' + '你要是希望我日常自己找活,比如每天稳定性、待填排期、未回的评测问题,得先给我' + '一条常驻授权,写清范围和能不能对外动手。没这条,我继续等你叫。'; +const _aigeLongReplyTwo = + '@Charlie 行,稳定性这条我接下来就按常驻授权来。先把范围钉死,免得我自己加戏。\n\n' + '每天看加州昨天的 AIGE Agame 稳定性,口径走现有日报:Agent 服务端昨天、Effect ' + 'supply 本月累计、AGame submission rate 近 7 天、客户端失败率和 P90 昨天。我自己拉,' + '不派 Stability Analyst,避免双开。只读数据。异常分开写事实、假设、未知。不提单、' + '不改生产、不往群里发——除非你另说。\n\n' + '默认播报发你飞书私信(Charlie the Shadow)。这个频道只在数字明显不对时顶一条,' + '平时不刷。\n\n' + '我还是被 @ 才醒。你要每天自动跑,得另设定时(Cursor loop 或 workflow),或者你' + '每天叫我一声。我自己醒不过来。\n\n' + '就差这两件你拍一下:播报只发你飞书,还是也要发某个群?唤醒是你每天 @ 我,还是' + '你去设定时?你定了我就写进记忆,从下一轮开始跟。'; + +NostrEvent _aigeHead() => _textMsg( + id: _aigeRootId, + pubkey: _aigeCharlie, + content: '@AIGE QA 我想问一下你有没有能力去自己给自己安排事情做?', + extraTags: const [ + ['p', _aigeBot], + ], +); + +/// The three replies; [lastReplyRepeats] > 1 makes the final reply taller +/// than the visible area so its tail settles below the composer. +List _aigeReplies({int lastReplyRepeats = 1}) => [ + _textMsg( + id: 'aige-reply-1', + pubkey: _aigeBot, + createdAt: 1100, + content: _aigeLongReplyOne, + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ['p', _aigeCharlie], + ], + ), + _textMsg( + id: 'aige-reply-2', + pubkey: _aigeCharlie, + createdAt: 1200, + content: '@AIGE QA 我可以给你常驻授权让你看一下每天的稳定性。', + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ['p', _aigeBot], + ], + ), + _textMsg( + id: 'aige-reply-3', + pubkey: _aigeBot, + createdAt: 1300, + content: List.filled(lastReplyRepeats, _aigeLongReplyTwo).join('\n\n'), + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ['p', _aigeCharlie], + ], + ), +]; + NostrEvent _systemMsg({ required String id, required Map payload, @@ -14029,6 +14108,611 @@ void main() { }, ); + ScrollPosition threadScrollPosition(WidgetTester tester) => tester + .state( + find + .descendant( + of: find.byKey(const ValueKey('thread-message-list')), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + + Future pushAigeThread( + WidgetTester tester, + NostrEvent head, { + List allMessages = const [], + }) async { + final timeline = formatTimeline([ + head, + ...allMessages, + ], currentPubkey: _aigeCharlie); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: timeline.first, + allMessages: timeline, + channelId: _channelId, + currentPubkey: _aigeCharlie, + isMember: true, + isArchived: false, + ), + ), + ); + } + + testWidgets( + 'iOS ordinary thread entry reveals the settled viewport at rest at the tail', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + final hydration = Completer>(); + await tester.pumpWidget( + _buildTestable( + messages: [head], + pendingThreadReplies: {_aigeRootId: hydration.future}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + ), + ); + await tester.pumpAndSettle(); + await pushAigeThread(tester, head); + await tester.pumpAndSettle(); + + hydration.complete(_aigeReplies()); + final gate = find.byKey( + const ValueKey('thread-initial-viewport-gate'), + ); + var revealed = false; + for (var frame = 0; frame < 60 && !revealed; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + revealed = tester.widget(gate).opacity == 1; + } + expect(revealed, isTrue, reason: 'The hydrated thread must reveal.'); + + final position = threadScrollPosition(tester); + expect( + position.pixels, + lessThanOrEqualTo(position.maxScrollExtent + 0.5), + reason: + 'The placement must not reveal while overscrolled past the ' + 'tail; iOS bouncing physics springs that back as a visible ' + 'entry bounce.', + ); + expect( + position.pixels, + greaterThanOrEqualTo(position.minScrollExtent - 0.5), + ); + expect( + position.isScrollingNotifier.value, + isFalse, + reason: 'The viewport must first paint at rest.', + ); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + + final tailAnchor = find.byKey(const ValueKey('thread-tail-anchor')); + final settledTailY = tester.getTopLeft(tailAnchor).dy; + for (var frame = 0; frame < 40; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + tester.getTopLeft(tailAnchor).dy, + closeTo(settledTailY, 0.5), + reason: 'frame $frame: the revealed thread must not drift.', + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + reason: 'frame $frame: Latest has nowhere to go at the tail.', + ); + } + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'ordinary thread entry exposes Latest when the settled tail ends below the composer', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + await tester.pumpWidget( + _buildTestable( + messages: [head], + threadReplies: {_aigeRootId: _aigeReplies(lastReplyRepeats: 3)}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + ), + ); + await tester.pumpAndSettle(); + await pushAigeThread(tester, head); + await tester.pumpAndSettle(); + + final position = threadScrollPosition(tester); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy; + expect( + position.extentAfter, + greaterThan(50), + reason: + 'Fixture: the last reply must be taller than the visible area ' + 'so the settle leaves its tail below the fold.', + ); + expect( + tester + .getBottomLeft( + find.byKey( + const ValueKey('thread-message-group-aige-reply-3'), + ), + ) + .dy, + greaterThan(composerTop), + ); + const latest = ValueKey('thread-jump-to-latest'); + expect( + find.byKey(latest), + findsOneWidget, + reason: + 'A settled tail below the composer needs Latest without ' + 'waiting for a drag; hiding the only way back strands the ' + 'reader.', + ); + + // iOS renders Latest as a native glass control; a press arrives + // over its platform channel rather than through Flutter hit testing. + const viewId = 7; + const glassChannel = MethodChannel( + 'buzz/jump_to_latest_glass/$viewId', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + glassChannel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(glassChannel, null), + ); + tester + .widget( + find.byKey(const ValueKey('thread-jump-to-latest-ios-glass')), + ) + .onPlatformViewCreated!(viewId); + await tester.pump(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + glassChannel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('pressed'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'thread Latest stays hidden while a programmatic scroll leaves and returns to the tail', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + await tester.pumpWidget( + _buildTestable( + messages: [head], + threadReplies: {_aigeRootId: _aigeReplies()}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + ), + ); + await tester.pumpAndSettle(); + await pushAigeThread(tester, head); + await tester.pumpAndSettle(); + + const latest = ValueKey('thread-jump-to-latest'); + final list = find.byKey(const ValueKey('thread-message-list')); + final position = threadScrollPosition(tester); + // Browse history and come back the way a reader does, so the entry + // gate is behind us and only tail geometry governs Latest. + await tester.drag(list, const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(find.byKey(latest), findsOneWidget); + await tester.drag(list, const Offset(0, -600)); + await tester.pumpAndSettle(); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + + // A correction or an iOS rubber-band moves the list with no finger + // on it and ends back at the tail. Intermediate positions report + // the tail out of view on every frame. + final away = position.animateTo( + position.maxScrollExtent - 120, + duration: const Duration(milliseconds: 160), + curve: Curves.linear, + ); + final back = away.then( + (_) => position.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 160), + curve: Curves.linear, + ), + ); + for (var frame = 0; frame < 24; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(latest), + findsNothing, + reason: + 'frame $frame: motion no finger started must not mount ' + 'Latest; on iOS that is a native control strobing.', + ); + } + await back; + await tester.pumpAndSettle(); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + + // Motion that ends off the tail is held while it moves, then the + // idle re-check releases the hold: Latest must come back on its + // own, or the reader has no way to the tail without a drag. + final strand = position.animateTo( + position.maxScrollExtent - 200, + duration: const Duration(milliseconds: 160), + curve: Curves.linear, + ); + for (var frame = 0; frame < 8; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(latest), + findsNothing, + reason: 'frame $frame: still moving, still held.', + ); + } + // Let the animation finish under the pump before awaiting it. + await tester.pumpAndSettle(); + await strand; + expect(position.extentAfter, greaterThan(100)); + expect( + find.byKey(latest), + findsOneWidget, + reason: 'An idle position off the tail must release the hold.', + ); + + // The released control is live: an iOS press reaches the tail. + const viewId = 11; + const glassChannel = MethodChannel( + 'buzz/jump_to_latest_glass/$viewId', + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + glassChannel, + (_) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(glassChannel, null), + ); + tester + .widget( + find.byKey(const ValueKey('thread-jump-to-latest-ios-glass')), + ) + .onPlatformViewCreated!(viewId); + await tester.pump(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + glassChannel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('pressed'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'a remote reply arriving at the thread tail does not flash Latest', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + final messagesNotifier = _FakeMessagesNotifier([head]); + await tester.pumpWidget( + _buildTestable( + messages: [head], + messagesNotifier: messagesNotifier, + threadReplies: {_aigeRootId: _aigeReplies()}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + ), + ); + await tester.pumpAndSettle(); + await pushAigeThread(tester, head); + await tester.pumpAndSettle(); + + const latest = ValueKey('thread-jump-to-latest'); + final position = threadScrollPosition(tester); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + + // The bot's next reply lands over the channel socket while the + // reader rests on the tail. The follow correction jumps before the + // new row is measured; that stale frame must not show Latest. + messagesNotifier.setMessages([ + head, + _textMsg( + id: 'aige-reply-4', + pubkey: _aigeBot, + createdAt: 2000, + content: '@Charlie 收到,明天早上我先跑一遍。', + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ['p', _aigeCharlie], + ], + ), + ]); + for (var frame = 0; frame < 12; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(latest), + findsNothing, + reason: + 'frame $frame: a reply arrival at the tail is followed, ' + 'not announced.', + ); + } + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-aige-reply-4')), + findsOneWidget, + ); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect(find.byKey(latest), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'iOS ordinary entry into a long short-reply thread reveals at rest at the tail', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + // Far enough down that the placement runs through the package's + // temporary second list rather than a direct scroll. + final replies = [ + for (var i = 0; i < 40; i += 1) + _textMsg( + id: 'aige-short-$i', + pubkey: i.isEven ? _aigeBot : _aigeCharlie, + createdAt: 1100 + i, + content: 'Reply $i', + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ], + ), + ]; + final hydration = Completer>(); + await tester.pumpWidget( + _buildTestable( + messages: [head], + pendingThreadReplies: {_aigeRootId: hydration.future}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + ), + ); + await tester.pumpAndSettle(); + await pushAigeThread(tester, head); + await tester.pumpAndSettle(); + + hydration.complete(replies); + final gate = find.byKey( + const ValueKey('thread-initial-viewport-gate'), + ); + var revealed = false; + for (var frame = 0; frame < 60 && !revealed; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + revealed = tester.widget(gate).opacity == 1; + } + expect(revealed, isTrue, reason: 'The hydrated thread must reveal.'); + + final position = threadScrollPosition(tester); + expect( + position.pixels, + inInclusiveRange( + position.minScrollExtent - 0.5, + position.maxScrollExtent + 0.5, + ), + reason: 'The swapped-in list must be revealed inside its range.', + ); + expect(position.isScrollingNotifier.value, isFalse); + expect(position.extentAfter, lessThanOrEqualTo(0.5)); + expect( + find.byKey(const ValueKey('thread-message-group-aige-short-39')), + findsOneWidget, + ); + final tailAnchor = find.byKey(const ValueKey('thread-tail-anchor')); + final settledTailY = tester.getTopLeft(tailAnchor).dy; + for (var frame = 0; frame < 40; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + tester.getTopLeft(tailAnchor).dy, + closeTo(settledTailY, 0.5), + reason: 'frame $frame: the revealed thread must not drift.', + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + } + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets( + 'a deep-linked thread does not unmount Latest between the provisional snapshot and the hydrated placement', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final head = _aigeHead(); + // Replies this phone sent stay in the local overlay until a thread + // query confirms them, so a deep link into the thread renders them + // as its provisional snapshot while that query is still in flight. + final localReplies = [ + for (var i = 1; i <= 3; i += 1) + _textMsg( + id: 'local-reply-$i', + pubkey: _aigeCharlie, + createdAt: 1000 + 100 * i, + content: i.isOdd ? _aigeLongReplyTwo : _aigeLongReplyOne, + extraTags: const [ + ['e', _aigeRootId, '', 'reply'], + ['p', _aigeBot], + ], + ), + ]; + final hydration = Completer>(); + final timeline = formatTimeline([ + head, + ...localReplies, + ], currentPubkey: _aigeCharlie); + await tester.pumpWidget( + _buildTestable( + messages: [head], + pendingThreadReplies: {_aigeRootId: hydration.future}, + localThreadReplies: {_aigeRootId: localReplies}, + users: _aigeUsers, + knownAgentPubkeys: const {_aigeBot}, + home: ThreadDetailPage( + threadHead: timeline.first, + allMessages: timeline, + channelId: _channelId, + currentPubkey: _aigeCharlie, + isMember: true, + isArchived: false, + initialMessageId: 'local-reply-3', + ), + ), + ); + + const latest = ValueKey('thread-jump-to-latest'); + final mounted = []; + for (var frame = 0; frame < 12; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + mounted.add(find.byKey(latest).evaluate().isNotEmpty); + } + hydration.complete(localReplies); + for (var frame = 0; frame < 30; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + mounted.add(find.byKey(latest).evaluate().isNotEmpty); + } + await tester.pumpAndSettle(); + mounted.add(find.byKey(latest).evaluate().isNotEmpty); + + expect( + mounted.first, + isFalse, + reason: + 'Latest must not mount against the provisional snapshot ' + 'while the relay query is still in flight: $mounted', + ); + final firstMount = mounted.indexOf(true); + expect( + firstMount, + isNonNegative, + reason: + 'The linked reply lands with its tail below the fold, so ' + 'Latest must mount after the placement: $mounted', + ); + expect( + mounted.sublist(firstMount).contains(false), + isFalse, + reason: + 'Once mounted at the hydrated placement Latest must stay; a ' + 'native control that unmounts and remounts strobes: $mounted', + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets('settleThreadScrollPositionInRange ends an iOS overscroll', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: ListView( + controller: controller, + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < 30; i += 1) const SizedBox(height: 100), + ], + ), + ), + ); + final position = controller.position; + expect(settleThreadScrollPositionInRange(null), isFalse); + expect(settleThreadScrollPositionInRange(position), isFalse); + + // A driven placement that lands past the extent leaves a spring behind. + position.jumpTo(position.maxScrollExtent + 120); + expect(position.pixels, position.maxScrollExtent + 120); + expect(position.isScrollingNotifier.value, isTrue); + expect(settleThreadScrollPositionInRange(position), isTrue); + expect(position.pixels, position.maxScrollExtent); + expect(position.isScrollingNotifier.value, isFalse); + await tester.pump(const Duration(milliseconds: 500)); + expect(position.pixels, position.maxScrollExtent); + expect(settleThreadScrollPositionInRange(position), isFalse); + + // Under-scroll past the leading edge is clamped the same way. + position.jumpTo(position.minScrollExtent - 80); + expect(settleThreadScrollPositionInRange(position), isTrue); + expect(position.pixels, position.minScrollExtent); + expect(position.isScrollingNotifier.value, isFalse); + }); + testWidgets('a reaction landing while the thread is open shows up there', ( tester, ) async {