fix(mobile): read mentions taller than the screen (#9243)

## Problem

Bug report (mobile 0.28.0 (46)): opening a mention from the Inbox in an
archived Solo chat leaves the Inbox row unread.

The video shows the cause. Each unread row is a long agent answer. On a
phone, the answer is taller than the screen, or it lands partly cut off
after the deep-link jump. Mobile automatic reading writes a `msg:` mark
only for a row that is fully visible for the 300 ms dwell. A mention row
is high priority, so `activity:<channel>` does not read it. The mention
can stay unread until the reader marks it read by hand.

The archived state is not the cause. A widget test shows that an
archived Solo chat writes read marks for short messages. Archived chats
make the problem easy to see, because there is no composer and the
reader only reads.

## Fix

A message counts as read only when it actually showed, for the 300 ms
dwell. A row counts when any of these holds:

- **It fits:** the whole row is inside the reading area (unchanged).
- **It fills the screen:** the row is taller than the reading area and
covers all of it. This applies to the channel timeline and the thread
view.
- **A link opened it:** the message a deep link or Inbox tap opened
counts once any part of it shows, because the jump places it at 35% and
a long one lands cut off.
- **It is the newest message at the live bottom:** its end is showing,
in a channel, a Solo chat (replies inline) or a thread. This covers a
long final answer that only ever shows its end.

Reaching the bottom does **not** read mentions or broadcasts above the
newest message that never came on screen. Desktop does that sweep
(block/buzz-app#1679). Mobile deliberately does not. Ordinary
`activity:` / `thread-activity:` catch-up is unchanged, and messages the
reader marked unread stay unread.

## Tests

- `reading_marks_test.dart`: the bottom does not read mentions above it;
the newest message is read at the live bottom (and respects manual
unread); a flat Solo timeline reads only its newest inline reply; a tall
thread tail reply is read at its end; DM catch-up does not mark
mentions; `isReadableRow` cases, including linked rows.
- `channel_detail_page_test.dart`: an archived Solo chat and a Solo
inline reply each read a tall newest mention at the bottom; a tall row
away from the bottom is read once it fills the screen; tall linked
messages (channel and thread) are read when the jump leaves them cut
off; in a thread, the tall last reply is read at the end and an earlier
tall reply is read once it fills the screen. These fail on `main`.
- `just mobile-check` and the full mobile suite pass.

## Human test steps

1. Get a long agent reply that mentions you in a Solo chat, then archive
the chat.
2. Open the row from the Inbox and hold still for about one second.
3. Go back. The row should now show as read, with no dot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: BlakeBot <85bbd70443139fd5c5762e34c2e214240939eb243c37f98380f69fed3b3bb4a6@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
This commit is contained in:
Blake McAnally
2026-11-09 13:13:55 -06:00
committed by GitHub
parent b082b1464c
commit c1ce2ca800
6 changed files with 704 additions and 60 deletions

View File

@@ -974,6 +974,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
null),
channelId: channel.id,
isDm: resolvedChannel.isDm,
flat: personalChat,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,

View File

@@ -20,6 +20,7 @@ class _MessageList extends HookConsumerWidget {
final String? welcomeAgent;
final ChannelMessagesNotifier notifier;
final bool isDm;
final bool flat;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
@@ -46,6 +47,7 @@ class _MessageList extends HookConsumerWidget {
this.welcomeAgent,
required this.notifier,
required this.isDm,
required this.flat,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
@@ -631,14 +633,16 @@ class _MessageList extends HookConsumerWidget {
0.01;
final visible = <TimelineMessage>[];
for (final position in itemPositionsListener.itemPositions.value) {
if (position.index >= displayEntries.length ||
position.itemLeadingEdge < bottomEdge ||
position.itemTrailingEdge > topEdge) {
continue;
if (position.index >= displayEntries.length) continue;
final group = displayEntries.reversed.elementAt(position.index);
if (isReadableRow(
position,
start: bottomEdge,
end: topEdge,
linked: group.any((e) => e.message.id == initialMessageId),
)) {
visible.addAll(group.map((entry) => entry.message));
}
final group =
displayEntries[displayEntries.length - 1 - position.index];
visible.addAll(group.map((entry) => entry.message));
}
final latest = entries.last.message;
final atBottom =
@@ -655,6 +659,7 @@ class _MessageList extends HookConsumerWidget {
loaded: allMessages,
visible: visible,
bottom: atBottom ? latest : null,
flat: flat,
);
final notifier = ref.read(readStateProvider.notifier);
// Reading to the bottom ends a manual "mark unread" on the channel.

View File

@@ -4,14 +4,15 @@ import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'timeline_message.dart';
import 'unread_badge/is_high_priority_event.dart';
/// How long a row must stay fully visible before it counts as read. This is
/// the same dwell the web and desktop app (`buzz-app`) uses.
/// How long a row must stay readable ([isReadableRow]) before it counts as
/// read. This is the same dwell the web and desktop app (`buzz-app`) uses.
const readingDwell = Duration(milliseconds: 300);
/// The read marks to write after a reading dwell, following `buzz-app`'s
@@ -21,19 +22,27 @@ const readingDwell = Duration(milliseconds: 300);
/// `activity:<channel>` and a thread writes `thread-activity:<root>`. These
/// catch-up marks read ordinary messages and that thread's replies. They do
/// not read mentions, broadcasts or DMs.
/// - Each fully visible message gets its own `msg:` mark unless its own mark,
/// the channel mark, or (for an ordinary top-level message) `activity:`
/// already reads it. A thread mark never counts here: a reply finds its
/// - Unlike `buzz-app`, the newest message itself is read at the live bottom,
/// in a thread too, so one taller than the screen is read at its end.
/// Mentions above it are read only on their own, never by reaching the
/// bottom.
/// - Each readable message (see [isReadableRow]) gets its own `msg:` mark
/// unless its own mark, the channel mark, or (for an ordinary top-level
/// message) `activity:` already reads it. A thread mark never counts here: a reply finds its
/// thread only while the root is loaded, so `buzz-app` keeps reply marks.
/// Unlike `buzz-app`, the message a link opened is readable once any part
/// of it shows, since a phone often cannot fit a long one.
/// - Automatic reading never writes the channel mark. Only an explicit
/// Mark read does that.
///
/// [loaded] is every message loaded for the channel or thread, including
/// replies. [visible] is the messages fully visible for the whole dwell.
/// replies. [visible] is the messages readable for the whole dwell; see
/// [isReadableRow].
/// [bottom] is the newest message when its bottom edge is visible at the
/// bottom of the list; otherwise null. Set [threadRootId] when reading a
/// thread, and [isRootThread] when its head is the thread root rather than a
/// nested reply. [now] is the current Unix time in seconds; it defaults to
/// nested reply. Set [flat] for a channel timeline that shows replies inline,
/// as a Solo chat does, so its newest reply is the bottom. [now] is the current Unix time in seconds; it defaults to
/// the clock. The result maps each read-state context to its new time.
Map<String, int> readingMarks({
required ReadStateState readState,
@@ -45,32 +54,19 @@ Map<String, int> readingMarks({
TimelineMessage? bottom,
String? threadRootId,
bool isRootThread = true,
bool flat = false,
int? now,
}) {
final marks = <String, int>{};
int? markerOf(String contextId) =>
maxReadAt([marks[contextId], readState.effectiveTimestamp(contextId)]);
// A nested thread view shows only one branch, so reaching its bottom does
// not read the whole thread.
if (bottom != null && (threadRootId == null || isRootThread)) {
final catchUp = _catchUpMark(
channelId: channelId,
loaded: loaded,
bottom: bottom,
threadRootId: threadRootId,
markerOf: markerOf,
now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
if (catchUp != null) marks[catchUp.key] = catchUp.value;
}
final self = currentPubkey?.toLowerCase();
for (final message in visible) {
if (message.isSystem || message.pubkey.toLowerCase() == self) continue;
bool needsMark(TimelineMessage message) {
if (message.isSystem || message.pubkey.toLowerCase() == self) return false;
final contextId = msgContextKey(message.id);
// Automatic reading keeps a message the reader marked unread.
if (readState.isForcedUnread(contextId)) continue;
if (readState.isForcedUnread(contextId)) return false;
final ordinary = readByChannelCatchUp(
isDm: isDm,
isReply: message.parentId != null && !_isBroadcast(message),
@@ -81,35 +77,87 @@ Map<String, int> readingMarks({
markerOf(channelId),
if (ordinary) markerOf(activityContextKey(channelId)),
]);
if (readAt != null && readAt >= message.createdAt) continue;
marks[contextId] = message.createdAt;
return readAt == null || readAt < message.createdAt;
}
void markMessage(TimelineMessage message) {
if (needsMark(message)) {
marks[msgContextKey(message.id)] = message.createdAt;
}
}
final liveBottom =
bottom != null &&
_showsNewest(
loaded: loaded,
bottom: bottom,
threadRootId: threadRootId,
flat: flat,
)
? bottom
: null;
// A nested thread view shows only one branch, so reaching its bottom does
// not read the whole thread.
if (liveBottom != null && (threadRootId == null || isRootThread)) {
final catchUp = _catchUpMark(
channelId: channelId,
bottom: liveBottom,
threadRootId: threadRootId,
markerOf: markerOf,
now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
if (catchUp != null) marks[catchUp.key] = catchUp.value;
}
// The newest row is read once its end shows at the live bottom, even when
// it is taller than the screen and never fills it, as at the end of a thread.
if (liveBottom != null) markMessage(liveBottom);
visible.forEach(markMessage);
return marks;
}
/// Whether a row at [position] counts as read: it lies inside the reading
/// area from [start] to [end], or it is taller than that area and covers all
/// of it. A [linked] row, the message a link opened, counts once any part of
/// it is inside: the jump can leave a long one cut off.
bool isReadableRow(
ItemPosition position, {
required double start,
required double end,
bool linked = false,
}) => linked
? position.itemLeadingEdge < end && position.itemTrailingEdge > start
: (position.itemLeadingEdge >= start && position.itemTrailingEdge <= end) ||
(position.itemLeadingEdge <= start &&
position.itemTrailingEdge >= end);
/// Whether [bottom] is the live bottom. A loaded message newer than the
/// bottom row means the list does not show it, for example after a jump
/// into history.
bool _showsNewest({
required Iterable<TimelineMessage> loaded,
required TimelineMessage bottom,
required String? threadRootId,
required bool flat,
}) => !loaded.any(
(message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
// The main timeline never renders these, so they can't be its bottom.
!isHiddenMembershipNotice(message) &&
message.createdAt > bottom.createdAt &&
(threadRootId != null
? _threadRootOf(message) == threadRootId
: flat || message.parentId == null || _isBroadcast(message)),
);
/// The catch-up mark for [bottom], or null when it would read nothing new.
MapEntry<String, int>? _catchUpMark({
required String channelId,
required Iterable<TimelineMessage> loaded,
required TimelineMessage bottom,
required String? threadRootId,
required int? Function(String contextId) markerOf,
required int now,
}) {
// A loaded message newer than the bottom row means the list does not show
// the live bottom, for example after a jump into history.
final showsNewest = !loaded.any(
(message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
// The main timeline never renders these, so they can't be its bottom.
!isHiddenMembershipNotice(message) &&
message.createdAt > bottom.createdAt &&
(threadRootId != null
? _threadRootOf(message) == threadRootId
: message.parentId == null || _isBroadcast(message)),
);
if (!showsNewest) return null;
// Never read beyond the last rendered message: a message between that
// row and a hidden lifecycle event may still arrive out of order.
final cut = bottom.createdAt < now ? bottom.createdAt : now;

View File

@@ -743,19 +743,23 @@ class ThreadDetailPage extends HookConsumerWidget {
1 - ((Grid.xs + navigationBottomInset) / viewportHeight) + 0.01;
final visible = <TimelineMessage>[];
for (final position in itemPositionsListener.itemPositions.value) {
if (position.itemLeadingEdge < topEdge ||
position.itemTrailingEdge > bottomEdge) {
continue;
}
if (position.index == headIndex) {
// The head is read like a reply. A thread opened directly, from
// a link or a notification, may be the only place it is seen.
if (!liveDeletionHidesHead) visible.add(liveHead);
continue;
}
// The head is read like a reply. A thread opened directly, from a
// link or a notification, may be the only place it is seen.
final replyIndex = position.index - indexForReply(0);
if (replyIndex < 0 || replyIndex >= replies.length) continue;
visible.add(replies[replyIndex]);
final message = position.index == headIndex
? (liveDeletionHidesHead ? null : liveHead)
: replyIndex < 0 || replyIndex >= replies.length
? null
: replies[replyIndex];
if (message != null &&
isReadableRow(
position,
start: topEdge,
end: bottomEdge,
linked: message.id == initialMessageId,
)) {
visible.add(message);
}
}
final isDm =
ref.read(channelsProvider.notifier).channelById(channelId)?.isDm ??

View File

@@ -2830,6 +2830,229 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('reading the bottom of an archived Solo chat reads a tall '
'newest mention', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
final solo = Channel(
id: _channelId,
name: 'Solo',
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
description: '',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
archivedAt: DateTime.utc(2025, 1, 2),
);
final messages = [
_textMsg(
id: 'tall',
pubkey: 'alice',
content: List.filled(120, 'A long answer line.').join('\n\n'),
createdAt: 1100,
extraTags: const [
['p', 'self'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
channel: solo,
personalChat: true,
signingPubkey: 'self',
channelMessagesNotifier: _FakeMessagesNotifier(messages),
readStateNotifier: readState,
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text('This channel is archived and read-only on mobile.'),
findsOneWidget,
);
// Only its end shows at the bottom, so it neither fits nor fills the
// screen; being the newest message reads it.
expect(readState.markedContexts, {
'activity:$_channelId': 1100,
'msg:tall': 1100,
});
expect(tester.takeException(), isNull);
});
testWidgets('a tall row away from the bottom is read once it fills the '
'screen', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'tall',
pubkey: 'alice',
content: List.filled(120, 'A long answer line.').join('\n\n'),
createdAt: 1100,
extraTags: const [
['p', 'self'],
],
),
for (var i = 0; i < 3; i++)
_textMsg(
id: 'later$i',
pubkey: 'alice',
content: List.filled(40, 'Later line.').join('\n\n'),
createdAt: 1200 + i,
),
],
readStateNotifier: readState,
),
);
await tester.pumpAndSettle();
readState.markedContexts.clear();
final messageList = find.byKey(const ValueKey('channel-message-list'));
tester
.widget<ScrollablePositionedList>(messageList)
.itemScrollController!
.jumpTo(index: 3, alignment: -0.5);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
final row = tester.getRect(
find.byKey(const ValueKey('channel-message-group-tall')),
);
final screen = tester.getRect(messageList);
expect(row.top, lessThan(screen.top));
expect(row.bottom, greaterThan(screen.bottom));
expect(readState.markedContexts, {'msg:tall': 1100});
});
testWidgets('a tall linked message is read when the jump leaves it cut '
'off', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'tall',
pubkey: 'alice',
content: List.filled(120, 'A long answer line.').join('\n\n'),
createdAt: 1100,
extraTags: const [
['p', 'self'],
],
),
for (var i = 0; i < 3; i++)
_textMsg(
id: 'later$i',
pubkey: 'alice',
content: List.filled(40, 'Later line.').join('\n\n'),
createdAt: 1200 + i,
),
],
initialMessageId: 'tall',
readStateNotifier: readState,
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
final row = tester.getRect(
find.byKey(const ValueKey('channel-message-group-tall')),
);
final screen = tester.getRect(
find.byKey(const ValueKey('channel-message-list')),
);
// The jump leaves the row's top above the screen, with later messages
// below it, so it neither fits nor fills the screen.
expect(row.top, lessThan(screen.top));
expect(row.bottom, lessThan(screen.bottom));
expect(readState.markedContexts, {'msg:tall': 1100});
});
testWidgets('reading the bottom of a Solo chat reads a tall newest '
'inline reply mention', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
final solo = Channel(
id: _channelId,
name: 'Solo',
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
description: '',
createdBy: 'self',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
);
final rootId = 'a' * 64;
final messages = [
_textMsg(
id: 'root',
pubkey: 'self',
content: 'Question',
createdAt: 1000,
),
_textMsg(
id: 'tall-reply',
pubkey: 'alice',
content: List.filled(120, 'A long answer line.').join('\n\n'),
createdAt: 1100,
extraTags: [
['e', rootId, '', 'reply'],
['p', 'self'],
],
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
channel: solo,
personalChat: true,
signingPubkey: 'self',
channelMessagesNotifier: _FakeMessagesNotifier(messages),
readStateNotifier: readState,
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(readState.markedContexts['msg:tall-reply'], 1100);
expect(tester.takeException(), isNull);
});
testWidgets('hidden huddle end does not block bottom catch-up', (
tester,
) async {
@@ -3280,6 +3503,179 @@ void main() {
expect(readState.markedContexts, {'msg:$threadIdRoot': 1000});
});
testWidgets('a tall linked thread reply is read when the jump leaves it '
'cut off', (tester) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
final root = _textMsg(
id: threadIdRoot,
pubkey: 'self',
content: 'Question',
createdAt: 1000,
);
final tall = _textMsg(
id: threadIdReplyZero,
pubkey: 'alice',
content: List.filled(120, 'A long answer line.').join('\n\n'),
createdAt: 1100,
extraTags: const [
['e', threadIdRoot, '', 'reply'],
['p', 'self'],
],
);
await tester.pumpWidget(
_buildTestable(
messages: [root, tall],
threadReplies: {
threadIdRoot: [tall],
},
readStateNotifier: readState,
home: ThreadDetailPage(
threadHead: formatTimeline([root]).single,
allMessages: formatTimeline([root, tall]),
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
initialMessageId: threadIdReplyZero,
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
final row = tester.getRect(
find.byKey(const ValueKey('thread-message-group-$threadIdReplyZero')),
);
final screen = tester.getRect(
find.byKey(const ValueKey('thread-message-list')),
);
// The jump leaves the reply's bottom below the screen, and its top
// well inside it, so it neither fits nor fills the screen.
expect(row.top, greaterThan(screen.top));
expect(row.bottom, greaterThan(screen.bottom));
expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100);
});
group('tall thread replies', () {
Future<_SynchronousReadStateNotifier> openThread(
WidgetTester tester,
List<NostrEvent> replies,
) async {
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {},
version: 0,
),
);
final root = _textMsg(
id: threadIdRoot,
pubkey: 'self',
content: 'Question',
createdAt: 1000,
);
await tester.pumpWidget(
_buildTestable(
messages: [root, ...replies],
threadReplies: {threadIdRoot: replies},
readStateNotifier: readState,
home: ThreadDetailPage(
threadHead: formatTimeline([root]).single,
allMessages: formatTimeline([root, ...replies]),
channelId: _channelId,
currentPubkey: 'self',
isMember: true,
isArchived: false,
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
return readState;
}
NostrEvent reply(String id, int createdAt, String content) => _textMsg(
id: id,
pubkey: 'alice',
content: content,
createdAt: createdAt,
extraTags: const [
['e', threadIdRoot, '', 'reply'],
['p', 'self'],
],
);
final tallContent = List.filled(120, 'A long answer line.').join('\n\n');
Rect rowOf(WidgetTester tester, String id) =>
tester.getRect(find.byKey(ValueKey('thread-message-group-$id')));
Rect screenOf(WidgetTester tester) =>
tester.getRect(find.byKey(const ValueKey('thread-message-list')));
testWidgets('the last reply is read at the end of the thread', (
tester,
) async {
final readState = await openThread(tester, [
reply(threadIdReplyZero, 1100, tallContent),
]);
await tester.fling(
find.byKey(const ValueKey('thread-message-list')),
const Offset(0, -20000),
4000,
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
// Resting at the end, its top is off screen, so it never fits or
// fills the screen.
final row = rowOf(tester, threadIdReplyZero);
expect(row.top, lessThan(screenOf(tester).top));
expect(row.bottom, lessThan(screenOf(tester).bottom));
expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100);
});
testWidgets('an earlier reply is read once it fills the screen', (
tester,
) async {
final later = indexedThreadId('reply-1');
final readState = await openThread(tester, [
reply(threadIdReplyZero, 1100, tallContent),
reply(later, 1200, 'Short follow-up'),
]);
final screen = screenOf(tester);
// The thread opens at its end: the tall reply's top is cut off, so
// it is not read yet.
var row = rowOf(tester, threadIdReplyZero);
expect(row.top, lessThan(screen.top));
expect(row.bottom, lessThan(screen.bottom));
expect(
readState.markedContexts,
isNot(contains('msg:$threadIdReplyZero')),
);
await tester.drag(
find.byKey(const ValueKey('thread-message-list')),
Offset(0, screen.bottom - row.bottom + screen.height / 2),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300));
row = rowOf(tester, threadIdReplyZero);
expect(row.top, lessThan(screen.top));
expect(row.bottom, greaterThan(screen.bottom));
expect(readState.markedContexts['msg:$threadIdReplyZero'], 1100);
});
});
testWidgets('rows under the Android keyboard are not read in history', (
tester,
) async {

View File

@@ -4,6 +4,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
const _channel = 'channel-1';
const _self = 'self';
@@ -209,6 +210,108 @@ void main() {
});
});
test('the bottom does not read mentions above it', () {
// Only a mention that showed is read. Reaching the bottom past one that
// never filled the screen leaves it unread.
final mention = _msg(
'm',
100,
tags: const [
['p', _self],
],
);
final broadcast = _msg(
'bc',
110,
parentId: 'root',
rootId: 'root',
tags: const [
['broadcast', '1'],
['p', _self],
],
);
final b = _msg('b', 200);
expect(_marks(loaded: [mention, broadcast, b], visible: [b], bottom: b), {
'activity:$_channel': 200,
});
});
test('the newest message is read at the bottom even when not visible', () {
// A mention taller than the screen shows only its end at the bottom.
final mention = _msg(
'm',
200,
tags: const [
['p', _self],
],
);
expect(
_marks(
readState: _state(const {}, {'msg:forced': _channel}),
loaded: [_msg('a', 100), mention],
bottom: mention,
),
{'activity:$_channel': 200, 'msg:m': 200},
);
final forced = _msg(
'forced',
200,
tags: const [
['p', _self],
],
);
expect(
_marks(
readState: _state(const {}, {'msg:forced': _channel}),
loaded: [forced],
bottom: forced,
),
{'activity:$_channel': 200},
);
});
test('catch-up does not mark a mention away from the live bottom', () {
final mention = _msg(
'm',
100,
tags: const [
['p', _self],
],
);
final a = _msg('a', 150);
final b = _msg('b', 200);
expect(_marks(loaded: [mention, a, b], visible: [a], bottom: a), {
'msg:a': 150,
});
});
test('a flat timeline reads only its newest inline reply', () {
final reply = _msg(
'r',
120,
parentId: 'root',
rootId: 'root',
tags: const [
['p', _self],
],
);
final b = _msg('b', 200, parentId: 'root', rootId: 'root');
Map<String, int> marks(TimelineMessage bottom) => readingMarks(
readState: _state(),
channelId: _channel,
isDm: false,
currentPubkey: _self,
loaded: [reply, b],
visible: const [],
bottom: bottom,
flat: true,
now: 10000,
);
expect(marks(b), {'activity:$_channel': 200, 'msg:b': 200});
// A newer inline reply means the bottom is not live.
expect(marks(reply), isEmpty);
});
test('away from the bottom, visible unread rows get message marks', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
@@ -271,6 +374,21 @@ void main() {
);
});
test('in a DM, catch-up does not mark mentions above the bottom', () {
final mention = _msg(
'mention',
100,
tags: const [
['p', _self],
],
);
final b = _msg('b', 200);
expect(_marks(isDm: true, loaded: [mention, b], bottom: b), {
'activity:$_channel': 200,
'msg:b': 200,
});
});
test('in a DM, catch-up does not read messages', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
@@ -282,6 +400,53 @@ void main() {
});
});
group('isReadableRow', () {
bool readable(double leading, double trailing) => isReadableRow(
ItemPosition(
index: 0,
itemLeadingEdge: leading,
itemTrailingEdge: trailing,
),
start: 0.1,
end: 0.9,
);
test('reads a row inside the reading area', () {
expect(readable(0.2, 0.8), isTrue);
expect(readable(0.1, 0.9), isTrue);
});
test('reads a row that covers the whole reading area', () {
expect(readable(-0.5, 1.4), isTrue);
expect(readable(0.1, 1.4), isTrue);
});
test('does not read a row cut off at one edge', () {
expect(readable(0.05, 0.5), isFalse);
expect(readable(0.5, 0.95), isFalse);
expect(readable(0.5, 2), isFalse);
expect(readable(-1, 0.5), isFalse);
});
test('reads a linked row once any part is inside the reading area', () {
bool linked(double leading, double trailing) => isReadableRow(
ItemPosition(
index: 0,
itemLeadingEdge: leading,
itemTrailingEdge: trailing,
),
start: 0.1,
end: 0.9,
linked: true,
);
expect(linked(0.35, 2), isTrue);
expect(linked(-1, 0.5), isTrue);
expect(linked(0.2, 0.8), isTrue);
expect(linked(-1, 0.1), isFalse);
expect(linked(0.9, 2), isFalse);
});
});
group('thread', () {
final root = _msg('root', 50);
TimelineMessage reply(String id, int at, {List<List<String>>? tags}) =>
@@ -346,6 +511,31 @@ void main() {
);
});
test('the tail reply is read at its end even when it never fits', () {
// A reply taller than the screen shows only its end at the tail, so it
// is never in [visible].
final mention = reply(
'r1',
100,
tags: const [
['p', _self],
],
);
expect(
_marks(loaded: [root, mention], bottom: mention, threadRootId: 'root'),
{'thread-activity:root': 100, 'msg:r1': 100},
);
// Away from the live tail it waits until it fills the screen.
expect(
_marks(
loaded: [root, mention, reply('r2', 200)],
bottom: mention,
threadRootId: 'root',
),
isEmpty,
);
});
test('a thread mark replaces catch-up but not reply marks', () {
final r1 = reply('r1', 100);
expect(