mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user