diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d43458ff8f..b54fce0bae 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -762,6 +762,18 @@ class ThreadDetailPage extends HookConsumerWidget { visible.add(message); } } + // The tail can sit at the scroll end while the keyboard still covers + // it, so it counts as the bottom only when its end is above the + // keyboard and composer. + final tailIndex = _threadTailIndex(replies.length); + final atBottom = + replies.isNotEmpty && + threadTailIsVisible() && + itemPositionsListener.itemPositions.value.any( + (position) => + position.index == tailIndex && + position.itemTrailingEdge <= bottomEdge, + ); final isDm = ref.read(channelsProvider.notifier).channelById(channelId)?.isDm ?? false; @@ -772,9 +784,7 @@ class ThreadDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, loaded: allMsgs, visible: visible, - bottom: replies.isNotEmpty && threadTailIsVisible() - ? replies.last - : null, + bottom: atBottom ? replies.last : null, threadRootId: queryRootId, isRootThread: threadHead.parentId == null, ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ac6778bc55..8548838305 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4212,6 +4212,125 @@ void main() { } }); + testWidgets( + 'a thread tail behind the Android keyboard is not read at the bottom', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 24); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + addTearDown( + () => tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ), + ); + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final root = _textMsg( + id: threadIdRoot, + pubkey: 'self', + content: 'Outer root', + createdAt: 1000, + ); + final branch = _textMsg( + id: threadIdReplyZero, + pubkey: 'self', + content: 'Branch head', + createdAt: 1001, + extraTags: const [ + ['e', threadIdRoot, '', 'reply'], + ], + ); + final replies = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: indexedThreadId('hidden-tail-$i'), + pubkey: 'alice', + content: 'Nested reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', threadIdRoot, '', 'root'], + ['e', threadIdReplyZero, '', 'reply'], + ['p', 'self'], + ], + ), + ]; + await tester.pumpWidget( + _buildTestable( + messages: [root, branch, ...replies], + threadReplies: { + threadIdRoot: [branch, ...replies], + }, + readStateNotifier: readState, + home: ThreadDetailPage( + threadHead: formatTimeline([branch]).single, + allMessages: formatTimeline([root, branch, ...replies]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: replies.first.id, + ), + ), + ); + await tester.pumpAndSettle(); + final list = find.byKey(const ValueKey('thread-message-list')); + final lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + tester + .widget(list) + .itemScrollController! + .jumpTo(index: replies.length, alignment: 0.8); + await tester.pumpAndSettle(); + lifecycle.onUserScrollEnd!(); + await tester.pumpAndSettle(); + final tail = find.byKey( + ValueKey('thread-message-group-${replies.last.id}'), + ); + final row = tester.getRect(tail); + final coveredTop = tester + .getTopLeft(find.byKey(const ValueKey('thread-composer-dock'))) + .dy; + expect( + row.top, + greaterThan(coveredTop), + reason: 'No part of the reply is in the reading area.', + ); + expect(row.bottom, lessThan(tester.getRect(list).bottom)); + expect(readState.markedContexts, isEmpty); + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + expect( + readState.markedContexts, + isNot(contains('msg:${replies.last.id}')), + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + tester.binding.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + } + }, + ); + testWidgets('thread rows under the Android keyboard are not read', ( tester, ) async {