From 473d592602162194c11c4ce48bb3d2d9cb0a20a4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 12 Nov 2026 06:28:36 -0800 Subject: [PATCH] fix(mobile): keep a thread tail behind the keyboard unread (#9295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to [#9243 read tall mentions](https://github.com/block/buzz/pull/9243). **Problem.** Since #9243, the newest reply in a thread is read once its end shows at the live bottom, even when it is taller than the screen. The thread view decides "at the tail" from scroll geometry that ignores the keyboard. On Android, with the keyboard open and tail-follow detached, the last reply can sit at the scroll end but entirely behind the keyboard, and it gets marked read. The mark stays after the keyboard closes, so a mention there can be cleared without ever being seen. **Fix.** The thread's live bottom now also requires the tail row's end to be inside the reading area, above the keyboard and composer. This matches how the channel timeline already checks its bottom row. ### Validation - Added test: a thread whose last reply sits behind the Android keyboard leaves it unread, including after the keyboard closes. It fails without the guard. - Full mobile suite and `flutter analyze` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../features/channels/thread_detail_page.dart | 16 ++- .../channels/channel_detail_page_test.dart | 119 ++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) 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 {