Fix mobile jump-to-latest flicker (#6807)

## Summary

- Keep jump-to-latest hidden through composer and keyboard layout
changes while the user is still following the tail.
- Stabilize thread tail detection during lazy scroll updates.
- Unmount inactive iOS Liquid Glass controls so the channel arrow cannot
bleed into an opened thread.

## Testing

- just mobile-check
- flutter test (1,864 passed)
- Signed iOS Release build and install on iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-08-28 17:12:36 +01:00
committed by GitHub
parent 86b9142a09
commit 58cc4b7e9b
7 changed files with 586 additions and 149 deletions

View File

@@ -437,8 +437,13 @@ class _MessageList extends HookConsumerWidget {
) -
composerBottomInset,
);
final hidesJumpToLatest = shouldHideChannelJumpToLatest(
isAtLatest: latestIsAtBoundary(),
followsLatest: followsLatest.value,
userHasDetached: hasUserScrolled.value,
);
final shouldShow =
!latestIsAtBoundary() &&
!hidesJumpToLatest &&
(hasUnseenLatestEntry.value ||
!latestIsVisible ||
distanceFromLatest.value > visiblePageHeight);

View File

@@ -33,6 +33,7 @@ class JumpToLatestButton extends HookWidget {
final nativeChannel = useState<MethodChannel?>(null);
final onPressedRef = useRef(onPressed)..value = onPressed;
final brightness = context.theme.brightness.name;
final routeIsCurrent = ModalRoute.isCurrentOf(context) ?? true;
useEffect(() {
final channel = nativeChannel.value;
@@ -101,6 +102,7 @@ class JumpToLatestButton extends HookWidget {
child: ValueListenableBuilder<bool>(
valueListenable: messageActionBackdropActive,
builder: (context, backdropActive, _) {
if (!routeIsCurrent) return const SizedBox.shrink();
if (!usesNativeIosGlass || backdropActive) {
return buildFlutterSurface();
}

View File

@@ -2,6 +2,17 @@ import 'package:flutter/material.dart';
import 'jump_to_latest_button.dart';
/// Whether channel Latest should remain hidden at the effective timeline tail.
///
/// Composer and keyboard layout updates can briefly make item-position
/// measurements stale. Preserve an active tail-follow intent through those
/// frames unless the user explicitly detached from the tail.
bool shouldHideChannelJumpToLatest({
required bool isAtLatest,
required bool followsLatest,
required bool userHasDetached,
}) => isAtLatest || (followsLatest && !userHasDetached);
/// Shared channel/thread visibility transition for [JumpToLatestButton].
class JumpToLatestSwitcher extends StatelessWidget {
final String id;
@@ -18,6 +29,19 @@ class JumpToLatestSwitcher extends StatelessWidget {
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final child = visible
? JumpToLatestButton(
key: ValueKey('$id-jump-to-latest'),
id: id,
onPressed: onPressed,
)
: SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden'));
if (Theme.of(context).platform == TargetPlatform.iOS) {
return KeyedSubtree(
key: ValueKey('$id-jump-to-latest-switcher'),
child: child,
);
}
return AnimatedSwitcher(
key: ValueKey('$id-jump-to-latest-switcher'),
duration: reduceMotion
@@ -36,13 +60,7 @@ class JumpToLatestSwitcher extends StatelessWidget {
child: child,
),
),
child: visible
? JumpToLatestButton(
key: ValueKey('$id-jump-to-latest'),
id: id,
onPressed: onPressed,
)
: SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden')),
child: child,
);
}
}

View File

@@ -1,17 +1,66 @@
part of 'thread_detail_page.dart';
/// Returns whether a bounded tail correction reached the effective end.
const _threadTailScrollTolerance = 0.5;
// Keep the direct-position correction finite in case the viewport cannot
// expose its tail (for example, continuously changing media dimensions).
const _latestTailCorrectionLimit = 8;
Widget _trackActiveThreadScrollPosition(
Widget child,
ObjectRef<ScrollPosition?> activePosition,
) => Builder(
builder: (context) {
activePosition.value = Scrollable.of(context).position;
return child;
},
);
bool _jumpActiveThreadScrollToTail(
ObjectRef<ScrollPosition?> activePosition,
bool Function()? testOverride,
) {
if (testOverride != null) return testOverride();
final position = activePosition.value;
if (position == null || !position.hasContentDimensions) return false;
// Moving the one active viewport avoids a second list and its visible bounce.
position.jumpTo(position.maxScrollExtent);
return true;
}
Future<bool> _animateActiveThreadScrollToTail(
BuildContext context,
ObjectRef<ScrollPosition?> activePosition,
) async {
final position = activePosition.value;
if (position == null || !position.hasContentDimensions) return false;
if (MediaQuery.disableAnimationsOf(context)) {
position.jumpTo(position.maxScrollExtent);
return true;
}
await position.animateTo(
position.maxScrollExtent,
duration: jumpToLatestScrollDuration,
curve: jumpToLatestScrollCurve,
);
return true;
}
/// Returns whether the thread is at its effective scroll end.
///
/// Item positions can lag the active scroll position by a frame, so an exact
/// end-of-scroll measurement is sufficient even while the tail item still
/// Item positions can lag or briefly oscillate during lazy layout, so an exact
/// end-of-scroll measurement remains authoritative even while the tail item
/// reports outside the visible boundary.
@visibleForTesting
bool threadTailCorrectionReachedEnd({
bool threadTailIsAtEffectiveEnd({
required bool tailIsLaidOut,
required bool tailIsVisible,
required double? extentAfter,
}) =>
tailIsVisible ||
(extentAfter != null && extentAfter <= _threadTailScrollTolerance);
(tailIsLaidOut &&
extentAfter != null &&
extentAfter <= _threadTailScrollTolerance);
int _threadTailIndex(int replyCount) => replyCount;

View File

@@ -60,11 +60,6 @@ const _landingHighlightDuration = Duration(seconds: 3);
const _landingHighlightDelay = Duration(milliseconds: 50);
const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
const _landingHighlightOpacity = 0.12;
const _threadTailScrollTolerance = 0.5;
// Keep the direct-position correction finite in case the viewport cannot
// expose its tail (for example, continuously changing media dimensions).
const _latestTailCorrectionLimit = 8;
/// Full-screen thread detail page.
///
@@ -79,6 +74,10 @@ class ThreadDetailPage extends HookConsumerWidget {
final bool isArchived;
final String? initialMessageId;
/// Overrides the tail jump only in deterministic lazy-layout tests.
@visibleForTesting
final bool Function()? jumpThreadTailForTesting;
const ThreadDetailPage({
super.key,
required this.threadHead,
@@ -88,6 +87,7 @@ class ThreadDetailPage extends HookConsumerWidget {
required this.isMember,
required this.isArchived,
this.initialMessageId,
this.jumpThreadTailForTesting,
});
@override
@@ -252,9 +252,15 @@ class ThreadDetailPage extends HookConsumerWidget {
final initialTailSettle = useMemoized(InitialThreadTailSettle.new);
final isAtThreadTail = useState(true);
final isNavigatingToThreadTail = useState(false);
// Ordinary thread entry owns a tail-follow intent before lazy item
// positions settle. Keep Latest suppressed through those initial layout
// frames; a deep-link entry must expose it for its older target instead.
final hidesLatestForInitialTailSettle = useState(initialMessageId == null);
final hidesLatestForComposerTailCorrection = useState(false);
final tailCorrectionInProgress = useRef(false);
final tailCorrectionGeneration = useRef(0);
final activeThreadScrollPosition = useRef<ScrollPosition?>(null);
final composerHasFocus = useListenable(composerFocusNode).hasFocus;
final viewportHeight = useListenable(listViewport.height).value;
final previousViewportHeight = useRef(viewportHeight);
final settledImeLift = usesFixedAndroidImeViewport
@@ -321,52 +327,35 @@ class ThreadDetailPage extends HookConsumerWidget {
final trailingBoundary =
1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001;
final lastMessageIndex = _threadTailIndex(replies.length);
return itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == lastMessageIndex &&
position.itemTrailingEdge <= trailingBoundary,
);
}
Widget trackActiveScrollPosition(Widget child) {
return Builder(
builder: (itemContext) {
activeThreadScrollPosition.value = Scrollable.of(
itemContext,
).position;
return child;
},
);
}
bool jumpActiveScrollPositionToTail() {
final position = activeThreadScrollPosition.value;
if (position == null || !position.hasContentDimensions) return false;
// Move the one active viewport to its exact end. Unlike indexed
// jumpTo/scrollTo, this does not reset or cross-fade through a second
// list, so iOS never exposes the intermediate top-of-thread frame.
position.jumpTo(position.maxScrollExtent);
return true;
}
Future<bool> animateActiveScrollPositionToTail() async {
final position = activeThreadScrollPosition.value;
if (position == null || !position.hasContentDimensions) return false;
if (MediaQuery.disableAnimationsOf(context)) {
position.jumpTo(position.maxScrollExtent);
return true;
var tailItemIsLaidOut = false;
var tailItemIsVisible = false;
for (final item in itemPositionsListener.itemPositions.value) {
if (item.index != lastMessageIndex) continue;
tailItemIsLaidOut = true;
tailItemIsVisible = item.itemTrailingEdge <= trailingBoundary;
break;
}
// Match the channel's visible Latest glide while moving only the active
// thread viewport. The indexed-list animation path can create a temporary
// second list for distant targets, which caused the old top-frame bounce.
await position.animateTo(
position.maxScrollExtent,
duration: jumpToLatestScrollDuration,
curve: jumpToLatestScrollCurve,
final position = activeThreadScrollPosition.value;
return threadTailIsAtEffectiveEnd(
tailIsLaidOut: tailItemIsLaidOut,
tailIsVisible: tailItemIsVisible,
extentAfter: position != null && position.hasContentDimensions
? position.extentAfter
: null,
);
return true;
}
Widget trackActiveScrollPosition(Widget child) =>
_trackActiveThreadScrollPosition(child, activeThreadScrollPosition);
bool jumpActiveScrollPositionToTail() => _jumpActiveThreadScrollToTail(
activeThreadScrollPosition,
jumpThreadTailForTesting,
);
Future<bool> animateActiveScrollPositionToTail() =>
_animateActiveThreadScrollToTail(context, activeThreadScrollPosition);
void finishThreadTailCorrection({
required bool revealViewport,
required int generation,
@@ -380,6 +369,7 @@ class ThreadDetailPage extends HookConsumerWidget {
tailIntent.isDragging ||
userOptedOutOfTailFollow.value) {
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
isAtThreadTail.value = threadTailIsVisible();
return;
@@ -387,7 +377,6 @@ class ThreadDetailPage extends HookConsumerWidget {
final reachedTail = threadTailIsVisible();
// Lazy children can revise maxScrollExtent for several frames. Keep
// moving the same active position until the measured tail is visible;
// the cap only guards pathological layouts that never stabilize.
if (!reachedTail && corrections < _latestTailCorrectionLimit) {
jumpActiveScrollPositionToTail();
WidgetsBinding.instance.addPostFrameCallback(
@@ -401,19 +390,10 @@ class ThreadDetailPage extends HookConsumerWidget {
return;
}
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
if (revealViewport) initialViewportReady.value = true;
// Item positions can trail the ScrollPosition by a frame after an
// animated jump. Once the bounded lazy-layout correction is exhausted,
// trust an exact end-of-scroll position too: there is nowhere further
// for Latest to navigate, so leaving the control visible is misleading.
final position = activeThreadScrollPosition.value;
isAtThreadTail.value = threadTailCorrectionReachedEnd(
tailIsVisible: reachedTail,
extentAfter: position != null && position.hasContentDimensions
? position.extentAfter
: null,
);
isAtThreadTail.value = reachedTail;
}
void correctThreadTailInstantly() {
@@ -432,6 +412,7 @@ class ThreadDetailPage extends HookConsumerWidget {
void followThreadTailFromComposer() {
if (userDragDetachedTailFollow.value) return;
hidesLatestForComposerTailCorrection.value = true;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailIntent.endDrag();
@@ -440,9 +421,20 @@ class ThreadDetailPage extends HookConsumerWidget {
followsThreadTail.value = true;
final reachedTail = threadTailIsVisible();
isAtThreadTail.value = reachedTail;
if (!reachedTail) correctThreadTailInstantly();
if (reachedTail) {
hidesLatestForComposerTailCorrection.value = false;
} else {
correctThreadTailInstantly();
}
}
useEffect(() {
if (!composerHasFocus) {
hidesLatestForComposerTailCorrection.value = false;
}
return null;
}, [composerHasFocus]);
useEffect(
() {
void onPositionsChanged() {
@@ -863,6 +855,8 @@ class ThreadDetailPage extends HookConsumerWidget {
child: _ThreadMessageList(
viewport: listViewport,
onUserScrollStart: () {
hidesLatestForInitialTailSettle.value = false;
hidesLatestForComposerTailCorrection.value = false;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailCorrectionInProgress.value = false;
@@ -981,6 +975,9 @@ class ThreadDetailPage extends HookConsumerWidget {
threadViewportVisible &&
hasFetchedReplies &&
!isNavigatingToThreadTail.value &&
!hidesLatestForInitialTailSettle.value &&
!hidesLatestForComposerTailCorrection.value &&
!(composerHasFocus && !userDragDetachedTailFollow.value) &&
!isAtThreadTail.value,
onPressed: scrollToThreadLatest,
),

View File

@@ -4129,6 +4129,16 @@ void main() {
);
await tester.tap(find.text('Message #general'));
for (var frame = 0; frame < 15; frame += 1) {
await tester.pump(const Duration(milliseconds: 16));
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
reason:
'Composer expansion must not expose Latest while tail-follow '
'layout catches up.',
);
}
await tester.pumpAndSettle();
expect(
@@ -4144,7 +4154,24 @@ void main() {
findsNothing,
);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
for (final inset in const [80.0, 160.0, 240.0, 300.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
reason:
'IME inset frames must not expose Latest while the followed '
'tail is being realigned.',
);
}
await tester.pump(androidImeMetricsSettleDelay);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsNothing,
reason:
'Latest must stay hidden when settled IME padding is applied.',
);
await tester.pumpAndSettle();
expect(latestMessage, findsOneWidget);
@@ -10823,6 +10850,96 @@ void main() {
);
});
testWidgets(
'iOS thread keeps Latest hidden through composer and keyboard frames',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
try {
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'A short thread',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 6; i++)
_textMsg(
id: 'reply-$i',
pubkey: i.isEven ? 'alice' : 'bob',
content: i.isEven ? 'hello' : 'testing',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
);
await tester.tap(find.text('Reply in thread…').hitTestable());
for (var frame = 0; frame < 15; frame += 1) {
await tester.pump(const Duration(milliseconds: 16));
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
reason:
'Composer expansion must not expose Latest while followed '
'tail geometry catches up.',
);
}
for (final inset in const [80.0, 160.0, 240.0, 300.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
reason:
'IME inset frames must not expose Latest while the composer '
'is following the thread tail.',
);
}
await tester.pumpAndSettle();
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
},
);
for (final replyCount in [0, 1]) {
testWidgets(
'cached writable $replyCount-reply thread defers dock correction until measured',
@@ -11365,75 +11482,94 @@ void main() {
testWidgets('short initial thread hydration remains top-anchored', (
tester,
) async {
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
_textMsg(
id: 'reply-1',
pubkey: 'bob',
content: 'First reply',
createdAt: 1100,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
_textMsg(
id: 'reply-2',
pubkey: 'bob',
content: 'Second reply',
createdAt: 1101,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
final completer = Completer<List<NostrEvent>>();
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
pendingThreadReplies: {'thread-root': completer.future},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
_textMsg(
id: 'reply-1',
pubkey: 'bob',
content: 'First reply',
createdAt: 1100,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
),
);
await tester.pumpAndSettle();
_textMsg(
id: 'reply-2',
pubkey: 'bob',
content: 'Second reply',
createdAt: 1101,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
final completer = Completer<List<NostrEvent>>();
final headFinder = find.byKey(
const ValueKey('thread-message-group-thread-root'),
);
final initialHeadY = tester.getTopLeft(headFinder).dy;
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
pendingThreadReplies: {'thread-root': completer.future},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
),
);
await tester.pumpAndSettle();
completer.complete(replies);
await tester.pumpAndSettle();
final threadHead = formatTimeline([rootEvent]).single;
Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: threadHead,
allMessages: [threadHead],
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
expect(headFinder, findsOneWidget);
expect(
find.byKey(const ValueKey('thread-message-group-reply-2')),
findsOneWidget,
);
expect(tester.getTopLeft(headFinder).dy, closeTo(initialHeadY, 0.5));
final headFinder = find.byKey(
const ValueKey('thread-message-group-thread-root'),
);
final initialHeadY = tester.getTopLeft(headFinder).dy;
const latestButton = ValueKey('thread-jump-to-latest');
expect(find.byKey(latestButton), findsNothing);
completer.complete(replies);
await tester.pump();
for (var frame = 0; frame < 8; frame++) {
expect(
find.byKey(latestButton),
findsNothing,
reason:
'Ordinary thread entry must not expose Latest on frame $frame.',
);
await tester.pump();
}
await tester.pumpAndSettle();
expect(headFinder, findsOneWidget);
expect(
find.byKey(const ValueKey('thread-message-group-reply-2')),
findsOneWidget,
);
expect(tester.getTopLeft(headFinder).dy, closeTo(initialHeadY, 0.5));
expect(find.byKey(latestButton), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets(
@@ -12807,6 +12943,81 @@ void main() {
);
}
testWidgets(
'thread shows Latest after composer tail correction exhausts and focus leaves',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final rootEvent = _textMsg(
id: 'thread-root',
pubkey: 'alice',
content: 'Thread root',
createdAt: 1000,
);
final replies = [
for (var i = 0; i < 30; i++)
_textMsg(
id: 'reply-$i',
pubkey: 'bob',
content: 'Reply $i',
createdAt: 1100 + i,
extraTags: const [
['e', 'thread-root', '', 'reply'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: [rootEvent],
threadReplies: {'thread-root': replies},
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'),
},
home: ThreadDetailPage(
threadHead: formatTimeline([rootEvent]).single,
allMessages: formatTimeline([rootEvent, replies[5]]),
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
initialMessageId: 'reply-5',
jumpThreadTailForTesting: () => true,
),
),
);
await tester.pumpAndSettle();
const latestButton = ValueKey('thread-jump-to-latest');
expect(find.byKey(latestButton), findsOneWidget);
await tester.tap(find.text('Reply in thread…').hitTestable());
await tester.pump();
for (var frame = 0; frame < 10; frame++) {
await tester.pump();
}
final focusNode = tester
.widget<TextField>(find.byType(TextField))
.focusNode!;
expect(focusNode.hasFocus, isTrue);
expect(
find.byKey(const ValueKey('thread-message-group-reply-29')),
findsNothing,
reason: 'The lazy tail must remain unlaid after bounded correction.',
);
focusNode.unfocus();
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isFalse);
expect(find.byKey(latestButton), findsOneWidget);
},
);
testWidgets('thread hides initial tail placement until it is settled', (
tester,
) async {
@@ -12983,19 +13194,35 @@ void main() {
expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsNothing);
});
test(
'thread tail accepts exact scroll extent while item positions lag',
() {
test('thread tail ignores oscillating item positions at exact extent', () {
for (final tailItemIsVisible in [true, false, false, true, false]) {
expect(
threadTailCorrectionReachedEnd(tailIsVisible: false, extentAfter: 0),
threadTailIsAtEffectiveEnd(
tailIsLaidOut: true,
tailIsVisible: tailItemIsVisible,
extentAfter: 0,
),
isTrue,
);
expect(
threadTailCorrectionReachedEnd(tailIsVisible: false, extentAfter: 1),
isFalse,
);
},
);
}
expect(
threadTailIsAtEffectiveEnd(
tailIsLaidOut: true,
tailIsVisible: false,
extentAfter: 1,
),
isFalse,
);
expect(
threadTailIsAtEffectiveEnd(
tailIsLaidOut: false,
tailIsVisible: false,
extentAfter: 0,
),
isFalse,
reason: 'A not-yet-laid-out lazy tail cannot trust stale extent.',
);
});
testWidgets('thread Latest settles across expanding lazy scroll extents', (
tester,
@@ -13154,6 +13381,21 @@ void main() {
findsNothing,
);
final landingScrollable = tester.state<ScrollableState>(
find.descendant(of: list, matching: find.byType(Scrollable)).first,
);
landingScrollable.position.jumpTo(
landingScrollable.position.maxScrollExtent - 24,
);
await tester.pump();
expect(
find.byKey(const ValueKey('thread-jump-to-latest')),
findsNothing,
reason:
'A stale landing measurement must not expose Latest before the '
'user explicitly browses history.',
);
await tester.drag(list, const Offset(0, 500));
await tester.pumpAndSettle();
@@ -13318,6 +13560,8 @@ void main() {
);
positionedList.itemScrollController!.jumpTo(index: 5);
await tester.pumpAndSettle();
await tester.drag(list, const Offset(0, 20));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('thread-message-group-reply-159')),
findsNothing,

View File

@@ -9,6 +9,38 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
void main() {
test(
'keeps channel Latest hidden while followed-tail geometry catches up',
() {
expect(
shouldHideChannelJumpToLatest(
isAtLatest: false,
followsLatest: true,
userHasDetached: false,
),
isTrue,
);
expect(
shouldHideChannelJumpToLatest(
isAtLatest: false,
followsLatest: true,
userHasDetached: true,
),
isFalse,
reason: 'A deliberate scroll away must still expose Latest.',
);
expect(
shouldHideChannelJumpToLatest(
isAtLatest: true,
followsLatest: false,
userHasDetached: true,
),
isTrue,
reason: 'Settled tail geometry remains authoritative.',
);
},
);
testWidgets('uses native iOS liquid glass outside message-action backdrops', (
tester,
) async {
@@ -107,6 +139,96 @@ void main() {
}
});
testWidgets('mounts and removes native iOS Latest without animating it', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
var visible = true;
late StateSetter update;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
update = setState;
return JumpToLatestSwitcher(
id: 'thread',
visible: visible,
onPressed: () {},
);
},
),
),
),
);
expect(find.byType(AnimatedSwitcher), findsNothing);
expect(find.byType(UiKitView), findsOneWidget);
update(() => visible = false);
await tester.pump();
expect(find.byType(UiKitView), findsNothing);
expect(
find.byKey(const ValueKey('thread-jump-to-latest-hidden')),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('unmounts native iOS Latest while its route is inactive', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) => Scaffold(
body: Column(
children: [
JumpToLatestButton(onPressed: () {}),
TextButton(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => const Scaffold(body: Text('Thread')),
),
),
child: const Text('Open thread'),
),
],
),
),
),
),
);
expect(find.byType(UiKitView, skipOffstage: false), findsOneWidget);
await tester.tap(find.text('Open thread'));
await tester.pumpAndSettle();
expect(find.text('Thread'), findsOneWidget);
expect(
find.byType(UiKitView, skipOffstage: false),
findsNothing,
reason: 'An underlying route must not keep a native glass layer alive.',
);
Navigator.of(tester.element(find.text('Thread'))).pop();
await tester.pumpAndSettle();
expect(find.byType(UiKitView, skipOffstage: false), findsOneWidget);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('gives thread controls thread-specific measurement keys', (
tester,
) async {