Files
buzz/mobile/test/features/channels/reading_marks_test.dart
klopez4212 42b395cdef Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 12:39:21 -04:00

397 lines
11 KiB
Dart

import 'package:buzz/features/channels/reading_marks.dart';
import 'package:buzz/features/channels/timeline_message.dart';
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';
const _channel = 'channel-1';
const _self = 'self';
TimelineMessage _msg(
String id,
int createdAt, {
String pubkey = 'alice',
String? parentId,
String? rootId,
List<List<String>> tags = const [],
}) => TimelineMessage(
id: id,
pubkey: pubkey,
createdAt: createdAt,
content: id,
tags: tags,
parentId: parentId,
rootId: rootId,
);
ReadStateState _state([
Map<String, int> contexts = const {},
Map<String, String> forced = const {},
]) => ReadStateState(
isReady: true,
pubkey: _self,
contexts: contexts,
version: 0,
forcedUnreadContexts: forced,
);
Map<String, int> _marks({
ReadStateState? readState,
bool isDm = false,
required List<TimelineMessage> loaded,
List<TimelineMessage> visible = const [],
TimelineMessage? bottom,
String? threadRootId,
bool isRootThread = true,
int now = 10000,
}) => readingMarks(
readState: readState ?? _state(),
channelId: _channel,
isDm: isDm,
currentPubkey: _self,
loaded: loaded,
visible: visible,
bottom: bottom,
threadRootId: threadRootId,
isRootThread: isRootThread,
now: now,
);
void main() {
test('hidden endings never advance catch-up past the visible row', () {
final bottom = _msg('visible', 100);
final end = TimelineMessage(
id: 'end',
pubkey: 'alice',
createdAt: 150,
content: '',
systemEvent: const SystemEvent(
type: SystemEventType.huddleEnded,
ephemeralChannelId: 'room',
),
);
Map<String, int> marks(List<TimelineMessage> loaded, int now) =>
readingMarks(
readState: _state(),
channelId: _channel,
isDm: false,
currentPubkey: _self,
loaded: [
TimelineMessage(
id: 'start',
pubkey: 'alice',
createdAt: 50,
content: '',
systemEvent: const SystemEvent(
type: SystemEventType.huddleStarted,
ephemeralChannelId: 'room',
),
),
...loaded,
],
visible: [],
bottom: bottom,
now: now,
);
expect(marks([bottom, end], 200)['activity:$_channel'], 100);
expect(marks([bottom, end], 125)['activity:$_channel'], 100);
expect(marks([bottom, end], 90)['activity:$_channel'], 90);
expect(marks([bottom, _msg('unseen', 120), end], 200), isEmpty);
final late = _msg('late', 120);
expect(
_marks(
readState: _state(marks([bottom, end], 200)),
loaded: [bottom, late, end],
visible: [late],
),
{'msg:late': 120},
);
});
group('channel timeline', () {
test('the bottom writes one catch-up mark instead of message marks', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
expect(_marks(loaded: [a, b], visible: [a, b], bottom: b), {
'activity:$_channel': 200,
});
});
test('catch-up cuts at the bottom row, not a newer reply', () {
// A top-level message created at 200 that arrives late must stay
// unread, so the cut cannot move up to the reply at 300.
final a = _msg('a', 100);
final reply = _msg('r', 300, parentId: 'a', rootId: 'a');
expect(_marks(loaded: [a, reply], visible: [a], bottom: a), {
'activity:$_channel': 100,
});
});
test('catch-up never passes the current time', () {
// A sender clock ahead of this one must not read messages that have
// not arrived yet.
final future = _msg('f', 20000);
expect(
_marks(loaded: [future], visible: [future], bottom: future, now: 10000),
{'activity:$_channel': 10000, 'msg:f': 20000},
);
});
test('a newer top-level message means the bottom is not live', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
expect(_marks(loaded: [a, b], visible: [a], bottom: a), {'msg:a': 100});
});
test('a newer broadcast reply means the bottom is not live', () {
final a = _msg('a', 100);
final broadcast = _msg(
'r',
300,
parentId: 'a',
rootId: 'a',
tags: const [
['broadcast', '1'],
],
);
expect(_marks(loaded: [a, broadcast], visible: [a], bottom: a), {
'msg:a': 100,
});
});
test('mentions and broadcasts keep their own marks at the bottom', () {
final mention = _msg(
'm',
100,
tags: const [
['p', _self],
],
);
final b = _msg('b', 200);
expect(_marks(loaded: [mention, b], visible: [mention, b], bottom: b), {
'activity:$_channel': 200,
'msg:m': 100,
});
});
test('away from the bottom, visible unread rows get message marks', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
final c = _msg('c', 300);
expect(
_marks(
readState: _state({'activity:$_channel': 150}),
loaded: [a, b, c],
visible: [a, b],
),
{'msg:b': 200},
);
});
test('never writes the channel mark', () {
final a = _msg('a', 100);
final marks = _marks(loaded: [a], visible: [a], bottom: a);
expect(marks.containsKey(_channel), isFalse);
});
test('writes nothing when existing marks already read everything', () {
final a = _msg('a', 100);
expect(
_marks(
readState: _state({_channel: 100}),
loaded: [a],
visible: [a],
bottom: a,
),
isEmpty,
);
expect(
_marks(
readState: _state({'activity:$_channel': 100}),
loaded: [a],
visible: [a],
bottom: a,
),
isEmpty,
);
});
test('skips own, system and manually unread messages', () {
final own = _msg('own', 100, pubkey: _self);
final system = TimelineMessage(
id: 'sys',
pubkey: 'alice',
createdAt: 110,
content: '',
isSystem: true,
);
final forced = _msg('forced', 120);
expect(
_marks(
readState: _state(const {}, {'msg:forced': _channel}),
loaded: [own, system, forced, _msg('later', 200)],
visible: [own, system, forced],
),
isEmpty,
);
});
test('in a DM, catch-up does not read messages', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
expect(_marks(isDm: true, loaded: [a, b], visible: [a, b], bottom: b), {
'activity:$_channel': 200,
'msg:a': 100,
'msg:b': 200,
});
});
});
group('thread', () {
final root = _msg('root', 50);
TimelineMessage reply(String id, int at, {List<List<String>>? tags}) =>
_msg(id, at, parentId: 'root', rootId: 'root', tags: tags ?? const []);
test('the tail writes one thread catch-up mark', () {
final r1 = reply('r1', 100);
final mention = reply(
'r2',
200,
tags: const [
['p', _self],
],
);
expect(
_marks(
loaded: [root, r1, mention],
visible: [r1, mention],
bottom: mention,
threadRootId: 'root',
),
// Thread catch-up reads a reply only while its root is loaded, so
// each visible reply keeps its own mark, as in `buzz-app`.
{'thread-activity:root': 200, 'msg:r1': 100, 'msg:r2': 200},
);
});
test('thread catch-up uses the newest reply, not newer channel rows', () {
final r1 = reply('r1', 100);
final later = _msg('later', 400);
expect(
_marks(
loaded: [root, r1, later],
visible: [r1],
bottom: r1,
threadRootId: 'root',
),
{'thread-activity:root': 100, 'msg:r1': 100},
);
});
test('a newer reply in another branch means the tail is not live', () {
final r1 = reply('r1', 100);
final nested = _msg('n1', 300, parentId: 'other', rootId: 'root');
expect(
_marks(
loaded: [root, r1, nested],
visible: [r1],
bottom: r1,
threadRootId: 'root',
),
{'msg:r1': 100},
);
});
test('away from the tail, visible replies get message marks', () {
final r1 = reply('r1', 100);
final r2 = reply('r2', 200);
expect(
_marks(loaded: [root, r1, r2], visible: [r1], threadRootId: 'root'),
{'msg:r1': 100},
);
});
test('a thread mark replaces catch-up but not reply marks', () {
final r1 = reply('r1', 100);
expect(
_marks(
readState: _state({'thread:root': 100}),
loaded: [root, r1],
visible: [r1],
bottom: r1,
threadRootId: 'root',
),
{'msg:r1': 100},
);
});
test('a nested thread head does not catch up the whole thread', () {
// Opened on a nested reply, the page shows one branch. An older
// mention in another branch must stay unread.
final branch = _msg('b1', 100, parentId: 'root', rootId: 'root');
final nested = _msg('n1', 200, parentId: 'b1', rootId: 'root');
expect(
_marks(
loaded: [root, branch, nested],
visible: [nested],
bottom: nested,
threadRootId: 'root',
isRootThread: false,
),
{'msg:n1': 200},
);
});
});
group('readingContentKey', () {
test('ignores a rebuilt list with the same messages', () {
final a = _msg('a', 100);
final b = _msg('b', 200);
expect(readingContentKey([a, b]), readingContentKey([a, b].toList()));
expect(readingContentKey([a, b]), isNot(readingContentKey([a])));
expect(
readingContentKey([a, b]),
isNot(readingContentKey([a, _msg('c', 300)])),
);
});
});
testWidgets('the dwell fires while the page rebuilds every 200 ms', (
tester,
) async {
// A typing indicator or stream rebuilds the page with a new message
// list. The dwell must still finish while the messages stay the same.
final positions = ValueNotifier<Object?>(null);
addTearDown(positions.dispose);
final tick = ValueNotifier<int>(0);
addTearDown(tick.dispose);
final a = _msg('a', 100);
var fired = 0;
await tester.pumpWidget(
ValueListenableBuilder<int>(
valueListenable: tick,
builder: (_, _, _) => HookBuilder(
builder: (_) {
final messages = [a];
useReadingDwell(
positions: positions,
active: true,
onDwell: () => fired++,
keys: [readingContentKey(messages)],
);
return const SizedBox();
},
),
),
);
// Rebuild at 200, 400 and 600 ms. A restarted dwell would not finish.
for (var i = 0; i < 3; i++) {
await tester.pump(const Duration(milliseconds: 200));
tick.value++;
await tester.pump();
}
expect(fired, 1);
});
}