fix(mobile): keep a thread tail behind the keyboard unread (#9295)

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-12 06:28:36 -08:00
committed by GitHub
parent c0e30110dd
commit 473d592602
2 changed files with 132 additions and 3 deletions

View File

@@ -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,
);

View File

@@ -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<KeyboardDismissOnDrag>(
find.byType(KeyboardDismissOnDrag),
);
lifecycle.onUserScrollStart!();
tester
.widget<ScrollablePositionedList>(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 {