Files
buzz/mobile/test/features/channels/channel_window_test.dart
klopez4212 85edc0572a feat(mobile): desktop-parity emoji and thread experience (#3485)
Brings the Flutter app's emoji and thread surfaces up to desktop parity.

## Emoji

- **Full emoji-mart dataset** generated from the same `@emoji-mart/data`
set desktop uses (1,870 emoji, 8 categories), committed as an asset — so
shortcodes, names, and keywords are identical across clients. `just
mobile-emoji-data` regenerates it.
- **Rebuilt the tray**: search (a Dart port of desktop's tiered
`emojiSearch` ranking, extended to names and keywords), a
frequently-used section, and one continuous scroll with pinned section
headers. The category rail is a shortcut into that list, not a page
switcher, and spans the full width the search field uses. Custom emoji
share the native glyph size and cell.
- **Reaction pills** match desktop's geometry, and the count shows at 1.
- **Emoji-only messages** render at 36px with 1.45em inline custom
emoji, matching desktop's `emojiOnly` treatment.
- **Positive-emoji burst** ported from desktop's `EmojiBurstProvider`,
suppressed under reduced motion.

## Threads

- **Top-down layout** — head first under the app bar, replies flowing
down, like desktop's thread panel. The old reversed list bottom-anchored
the content and jammed the head against the composer.
- **Tap a channel message to open its thread**; long-press still opens
the action sheet.
- **Live reactions.** The thread's relay query is one-shot and its
`kinds` filter carries only content rows, so a reaction event could not
reach an open thread at all, and `allMessages` was a snapshot frozen
when the route was pushed — a new pill only appeared after leaving and
re-entering, which refetched. The live channel events are now unioned
into the thread's list. The burst is also route-guarded, since the
channel timeline stays mounted underneath and was claiming it first.
- The `+` affordance follows the channel: replies stay bare until they
carry a reaction, and the head keeps a standing `+`.

## Keyboard

A deliberate downward drag past ~48px dismisses the keyboard; short
scrolls leave it alone. Applies to the channel list, the thread list,
and the compose bar (via a raw `Listener`, so it can't steal the field's
tap or selection drags).

True finger-tracking dismissal is out of scope — Flutter only offers
`manual`/`onDrag`, and 1:1 tracking needs a native `UIScrollView` proxy
plus Android's `WindowInsetsAnimationController`.

## Testing

`just mobile-check` and `just mobile-test` pass (965 tests). New
coverage for emoji search ranking, dataset parsing, the emoji-only
predicate, tray scroll/rail behavior, reaction pills and the burst, and
both thread fixes above. `just ci` green.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: npub12zsjdqx8dud99s9h47xmk9lq93vryf7zjrae8wdrmma52cg5yglseyulst <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
Signed-off-by: klopez4212 <klopez4212@gmail.com>
Co-authored-by: npub12zsjdqx8dud99s9h47xmk9lq93vryf7zjrae8wdrmma52cg5yglseyulst <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
2026-07-30 07:29:40 -07:00

524 lines
15 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/channel_window.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
group('parseChannelWindowResponse', () {
test('requires exactly one matching bounds event', () {
expect(
() => parseChannelWindowResponse([_row('a')], _channelId, null),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(), _bounds(id: 'b2')],
_channelId,
null,
),
throwsException,
);
expect(
() => parseChannelWindowResponse(
[_row('a'), _bounds(dTag: 'wrong:head')],
_channelId,
null,
),
throwsException,
);
});
test('rejects hasMore and nextCursor disagreement', () {
expect(
() => parseChannelWindowResponse(
[
_row('a'),
_bounds(content: {'has_more': true, 'next_cursor': null}),
],
_channelId,
null,
),
throwsException,
);
});
test('attaches summaries and keeps overlays out of rows', () {
final page = parseChannelWindowResponse(
[
_row('root'),
_summary('root', replyCount: 3, participants: ['p1', 'p2']),
_reaction('reaction'),
_bounds(),
],
_channelId,
null,
);
expect(page.rows.map((row) => row.event.id), ['root']);
expect(page.rows.single.thread?.replyCount, 3);
expect(page.rows.single.thread?.participantPubkeys, ['p1', 'p2']);
expect(page.aux.map((event) => event.id), ['reaction']);
});
});
group('ChannelWindowStore', () {
test('rejects cursor interval and row order violations', () {
expect(
() => appendOlderChannelWindow(
replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('head', createdAt: 20)], hasMore: true),
),
_page(
startCursor: const ChannelPageCursor(
createdAt: 20,
eventId: 'head',
),
rows: [_row('newer', createdAt: 21)],
),
),
throwsException,
);
expect(
() => replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('b', createdAt: 9), _row('a', createdAt: 10)]),
),
throwsException,
);
});
test('rejects overlapping older pages and drops tail on head refresh', () {
final head = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10)], hasMore: true),
);
final withOlder = appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('z', createdAt: 9)],
),
);
expect(withOlder.pages, hasLength(2));
expect(
() => appendOlderChannelWindow(
head,
_page(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'a'),
rows: [_row('a', createdAt: 9)],
),
),
throwsException,
);
final refreshed = replaceNewestChannelWindow(
withOlder,
_page(rows: [_row('b', createdAt: 11)]),
);
expect(refreshed.pages, hasLength(1));
expect(refreshed.pages.single.rows.single.event.id, 'b');
});
test('drops live rows at or older than oldest loaded boundary', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)]),
);
final ignored = mergeLiveChannelWindowEvent(
store,
_row('z', createdAt: 8),
isTimelineRow: true,
);
expect(identical(ignored, store), isTrue);
final merged = mergeLiveChannelWindowEvent(
store,
_row('live', createdAt: 11),
isTimelineRow: true,
);
expect(merged.liveOverlay.map((event) => event.id), ['live']);
});
});
group('live thread summaries', () {
test('a live summary gives a loaded row its reply count', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
// No summary yet: the row was fetched before anyone replied.
expect(channelWindowThreadSummaries(store), isEmpty);
final merged = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(merged)['root']?.replyCount, 1);
});
test('a live summary covers a root that is only in the overlay', () {
// A message you just sent has no page row yet, so its count has nowhere
// to live except beside the pages.
final store = mergeLiveChannelWindowEvent(
replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('old', createdAt: 10)]),
),
_row('mine', createdAt: 11),
isTimelineRow: true,
);
final merged = mergeLiveChannelWindowEvent(
store,
_summary('mine', replyCount: 2, participants: ['p1', 'p2']),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(merged)['mine']?.replyCount, 2);
});
test('a later live summary replaces an earlier one', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1'], createdAt: 11),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_summary(
'root',
replyCount: 2,
participants: ['p1', 'p2'],
createdAt: 12,
),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 2);
});
test(
'an older live summary is ignored but a later same-second summary wins',
() {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 3, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
final afterOlder = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1'], createdAt: 29),
isTimelineRow: false,
);
final afterSameSecond = mergeLiveChannelWindowEvent(
afterOlder,
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
expect(identical(afterOlder, store), isTrue);
expect(
channelWindowThreadSummaries(afterSameSecond)['root']?.replyCount,
2,
);
},
);
test(
'a summary received during the initial query wins the page snapshot',
() {
var store = mergeLiveChannelWindowEvent(
const ChannelWindowStore.empty(),
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
// The page overlay can be signed after its query snapshot.
threadSummaryCreatedAt: 31,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
retainLiveSummaryRootIds: const {'root'},
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 2);
},
);
test('a replayed summary yields to a newer initial page snapshot', () {
var store = mergeLiveChannelWindowEvent(
const ChannelWindowStore.empty(),
_summary('root', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 31,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 1);
});
test('a newer live summary survives an appended older page', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('head', createdAt: 10)], hasMore: true),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('older', replyCount: 2, participants: ['p1'], createdAt: 30),
isTimelineRow: false,
);
store = appendOlderChannelWindow(
store,
ChannelWindowPage(
startCursor: const ChannelPageCursor(createdAt: 10, eventId: 'head'),
rows: [
ChannelWindowRow(
event: _row('older', createdAt: 9),
thread: const ChannelWindowThreadSummary(
replyCount: 1,
descendantCount: 1,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 29,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(store)['older']?.replyCount, 2);
});
test('a refetched row outranks the live entry it duplicates', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
// Refetching the head brings down the authoritative count with the row,
// so the stale live entry must not shadow it.
final refreshed = replaceNewestChannelWindow(
store,
ChannelWindowPage(
startCursor: null,
rows: [
ChannelWindowRow(
event: _row('root', createdAt: 10),
thread: const ChannelWindowThreadSummary(
replyCount: 5,
descendantCount: 5,
lastReplyAt: 12,
participantPubkeys: ['p1'],
),
threadSummaryCreatedAt: 20,
),
],
aux: const [],
nextCursor: null,
hasMore: false,
),
);
expect(channelWindowThreadSummaries(refreshed)['root']?.replyCount, 5);
});
test('a summary survives unrelated live events', () {
var store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
store = mergeLiveChannelWindowEvent(
store,
_summary('root', replyCount: 1, participants: ['p1']),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_reaction('reaction'),
isTimelineRow: false,
);
store = mergeLiveChannelWindowEvent(
store,
_row('later', createdAt: 11),
isTimelineRow: true,
);
expect(channelWindowThreadSummaries(store)['root']?.replyCount, 1);
});
test('a summary with no root tag or bad content is ignored', () {
final store = replaceNewestChannelWindow(
const ChannelWindowStore.empty(),
_page(rows: [_row('root', createdAt: 10)]),
);
final untagged = mergeLiveChannelWindowEvent(
store,
_event(id: 'no-e', kind: EventKind.channelThreadSummary, content: '{}'),
isTimelineRow: false,
);
expect(identical(untagged, store), isTrue);
final malformed = mergeLiveChannelWindowEvent(
store,
_event(
id: 'bad',
kind: EventKind.channelThreadSummary,
tags: const [
['e', 'root'],
],
content: 'not json',
),
isTimelineRow: false,
);
expect(channelWindowThreadSummaries(malformed), isEmpty);
});
});
}
const _channelId = 'ABCDEF';
ChannelWindowPage _page({
ChannelPageCursor? startCursor,
required List<NostrEvent> rows,
bool hasMore = false,
}) {
return ChannelWindowPage(
startCursor: startCursor,
rows: [for (final row in rows) ChannelWindowRow(event: row)],
aux: const [],
nextCursor: hasMore
? ChannelPageCursor(
createdAt: rows.last.createdAt,
eventId: rows.last.id,
)
: null,
hasMore: hasMore,
);
}
NostrEvent _row(String id, {int createdAt = 10}) => _event(
id: id,
createdAt: createdAt,
kind: EventKind.streamMessageV2,
tags: const [
['h', _channelId],
],
);
NostrEvent _reaction(String id) => _event(
id: id,
kind: EventKind.reaction,
tags: const [
['e', 'root'],
],
);
NostrEvent _summary(
String rootId, {
required int replyCount,
required List<String> participants,
int createdAt = 10,
}) => _event(
id: 'summary-$rootId-$createdAt-$replyCount',
createdAt: createdAt,
kind: EventKind.channelThreadSummary,
tags: [
['e', rootId],
],
content: jsonEncode({
'reply_count': replyCount,
'descendant_count': replyCount,
'last_reply_at': 12,
'participants': participants,
}),
);
NostrEvent _bounds({
String id = 'bounds',
String? dTag,
Map<String, dynamic>? content,
}) => _event(
id: id,
kind: EventKind.channelWindowBounds,
tags: [
['d', dTag ?? '${_channelId.toLowerCase()}:head'],
],
content: jsonEncode(content ?? {'has_more': false, 'next_cursor': null}),
);
NostrEvent _event({
required String id,
int createdAt = 10,
required int kind,
List<List<String>> tags = const [],
String content = '',
}) {
return NostrEvent(
id: id,
pubkey: 'alice',
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: 'sig',
);
}