mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user