mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
fix(mobile): bound notices to loaded message history (#9062)
## Summary Mobile reads system notices older than its loaded message window, even though those notices cannot yet appear. Bound notice reads by the oldest loaded message's timestamp to avoid fetching history outside the loaded time range. A short or empty bounded response finishes that time range, rather than all notice history. Preserve the full boundary second, resume when older message pages load, and keep notices before the first message available at the start of history. Enforce the 2,000-notice cap before fetching reactions. Reuse cached reactions and deletions when older pages reveal their notices. For channels with fewer than 500 notices, scrolling back can now require one bounded notice read per older message page. Previously, one unbounded read covered the channel. ### Related issue Related: #8962, which changed how mobile loads and displays membership notices. No duplicate notice-bound PR found in the open mobile-notice title search. ### Testing Mutation checks: removing the production `since` argument or restoring the old unconditional history-end flag each made all four notice-range cases fail. Command: `cd mobile && flutter test --no-pub --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example test/features/channels/channel_notices_journey_test.dart --name 'notice ranges'`. Replacing the captured message cursor with a fabricated maximum ID also fails the empty-range progress regression. Command: `cd mobile && flutter test --no-pub --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example test/features/channels/channel_messages_provider_test.dart --name 'an empty notice range'`. A read-only prepared-query check on local Postgres 17.10 used a representative kind-40099 query. It held 22 event-related relation locks with `force_custom_plan` and 99 with `force_generic_plan`. The generic plan pruned seven of eight partitions during execution but retained the broader locks. These results do not establish lock reduction or throughput improvement under the relay's actual cached-plan workload; a relay load test remains pending. Native app scrolling against a live relay and human confirmation remain pending. For the device check, run `just mobile-dev` on a host with an iOS Simulator runtime. Connect to a local test relay and open a channel with more than 150 messages and interleaved topic notices. Scroll back through at least three message pages, then to the start. Check that visible notices remain in order and appear once. Disconnect during a page read, reconnect, and continue scrolling to check recovery. Generated with Codex --------- Signed-off-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz> Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -64,30 +64,52 @@ extension _ChannelNotices on ChannelMessagesNotifier {
|
||||
!isChannelCursorOlder(floor, cursor)) {
|
||||
break;
|
||||
}
|
||||
final events = await session.queryRelay([
|
||||
final page = await session.queryRelay([
|
||||
NostrFilters.channelNotices(
|
||||
channelId,
|
||||
since: floor?.createdAt,
|
||||
until: cursor?.createdAt,
|
||||
beforeId: cursor?.eventId,
|
||||
),
|
||||
]);
|
||||
if (!_isCurrentInit(initVersion)) return;
|
||||
if (events.any(
|
||||
if (page.any(
|
||||
(event) =>
|
||||
event.kind != EventKind.systemMessage ||
|
||||
event.channelId != channelId,
|
||||
)) {
|
||||
throw Exception('System notices are outside their channel.');
|
||||
}
|
||||
// Short bounded ranges can leave a partial page's worth of room.
|
||||
// Count unique additions, keep the newest, and exclude overflow
|
||||
// before fetching auxiliary events. Cached notices and their aux
|
||||
// already come from _windowEvents when an older page reveals them.
|
||||
final fresh =
|
||||
{
|
||||
for (final event in page)
|
||||
if (!_notices.containsKey(event.id)) event.id: event,
|
||||
}.values.toList()..sort(
|
||||
(a, b) => compareChannelTimelineEventsChronologically(b, a),
|
||||
);
|
||||
final events = fresh.take(_maxNotices - _notices.length).toList();
|
||||
// Pages carry their rows' reactions; notices read apart need their
|
||||
// own. Read them before the notices count as read, so a failure
|
||||
// reads the whole page again.
|
||||
final aux = await _readNoticeAux(session, events);
|
||||
if (!_isCurrentInit(initVersion)) return;
|
||||
if (events.length < NostrFilters.channelNoticePageSize) {
|
||||
_noticesReachedStart = true;
|
||||
if (page.length < NostrFilters.channelNoticePageSize) {
|
||||
if (floor == null) {
|
||||
_noticesReachedStart = true;
|
||||
} else {
|
||||
// Only this range is exhausted. Resume below its message floor
|
||||
// when older pages arrive, even if it was empty. Same-second
|
||||
// notices below the floor are retained and may be read again;
|
||||
// merging by id keeps them unique. Use the captured floor: a
|
||||
// page may have extended the window while this read was in flight.
|
||||
_noticeCursor = floor;
|
||||
}
|
||||
} else {
|
||||
_noticeCursor = _nextCursor(events, cursor, 'System notices');
|
||||
_noticeCursor = _nextCursor(page, cursor, 'System notices');
|
||||
}
|
||||
for (final event in events) {
|
||||
_notices[event.id] = event;
|
||||
|
||||
@@ -50,9 +50,11 @@ abstract final class NostrFilters {
|
||||
);
|
||||
|
||||
/// A channel's system notices (kind:40099), newest first, below the
|
||||
/// `until` + `before_id` cursor the channel-window pages use.
|
||||
/// `until` + `before_id` cursor the channel-window pages use. [since]
|
||||
/// includes the oldest loaded row's whole second, including id ties.
|
||||
static NostrFilter channelNotices(
|
||||
String channelId, {
|
||||
int? since,
|
||||
int? until,
|
||||
String? beforeId,
|
||||
}) => NostrFilter(
|
||||
@@ -61,6 +63,7 @@ abstract final class NostrFilters {
|
||||
'#h': [channelId],
|
||||
},
|
||||
limit: channelNoticePageSize,
|
||||
since: since,
|
||||
until: until,
|
||||
extensions: {'before_id': ?beforeId},
|
||||
);
|
||||
|
||||
@@ -514,6 +514,8 @@ void main() {
|
||||
});
|
||||
|
||||
test('notices read apart keep their reactions', () async {
|
||||
const id = _paddedId;
|
||||
final joinedId = id('20');
|
||||
NostrEvent aux(
|
||||
String id,
|
||||
int kind,
|
||||
@@ -534,14 +536,24 @@ void main() {
|
||||
);
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[_event(id: 'message', createdAt: 10), _bounds()],
|
||||
[
|
||||
_event(id: id('10'), createdAt: 50),
|
||||
_bounds(hasMore: true, cursorCreatedAt: 50, cursorId: id('10')),
|
||||
],
|
||||
[
|
||||
_event(id: id('30'), createdAt: 50),
|
||||
_bounds(dTag: '${_channelId.toLowerCase()}:50:${id('10')}'),
|
||||
],
|
||||
],
|
||||
);
|
||||
relaySession.noticeResults.add([_joinNotice('joined', 20)]);
|
||||
relaySession.noticeResults.addAll([
|
||||
[_joinNotice(joinedId, 50)],
|
||||
[_joinNotice(joinedId, 50)],
|
||||
]);
|
||||
relaySession.auxEvents.addAll([
|
||||
aux('kept', EventKind.reaction, 'joined', content: '👍'),
|
||||
aux('removed', EventKind.reaction, 'joined', content: '🎉'),
|
||||
aux('unreacted', EventKind.reaction, 'joined', content: '👀'),
|
||||
aux('kept', EventKind.reaction, joinedId, content: '👍'),
|
||||
aux('removed', EventKind.reaction, joinedId, content: '🎉'),
|
||||
aux('unreacted', EventKind.reaction, joinedId, content: '👀'),
|
||||
aux('removal', EventKind.deletion, 'removed'),
|
||||
aux('channel-less', EventKind.deletion, 'unreacted', inChannel: false),
|
||||
]);
|
||||
@@ -564,7 +576,7 @@ void main() {
|
||||
null,
|
||||
],
|
||||
);
|
||||
expect(reads[0].tags['#e'], ['joined']);
|
||||
expect(reads[0].tags['#e'], [joinedId]);
|
||||
expect(
|
||||
reads[2].tags['#e'],
|
||||
unorderedEquals(['kept', 'removed', 'unreacted']),
|
||||
@@ -572,10 +584,43 @@ void main() {
|
||||
for (final read in reads.skip(1)) {
|
||||
expect(read.kinds, isNot(contains(EventKind.reaction)));
|
||||
}
|
||||
// The notice is cached below the current same-second message floor.
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.requireValue
|
||||
.where((event) => event.id == joinedId),
|
||||
isEmpty,
|
||||
);
|
||||
relaySession.emit(aux('live', EventKind.reaction, joinedId, content: '🔥'));
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
expect(await notifier.fetchOlder(), isTrue);
|
||||
await _pumpEventQueue();
|
||||
expect(
|
||||
reads,
|
||||
hasLength(4),
|
||||
reason: 'cached notices need no auxiliary reread',
|
||||
);
|
||||
final joined = formatTimeline(
|
||||
container.read(channelMessagesProvider(_channelId)).value!,
|
||||
).singleWhere((message) => message.id == 'joined');
|
||||
expect(joined.reactions.map((reaction) => reaction.emoji), ['👍']);
|
||||
).singleWhere((message) => message.id == joinedId);
|
||||
expect(
|
||||
joined.reactions.map((reaction) => reaction.emoji),
|
||||
unorderedEquals(['👍', '🔥']),
|
||||
);
|
||||
relaySession.emit(aux('live-removal', EventKind.deletion, 'kept'));
|
||||
await _pumpEventQueue();
|
||||
expect(
|
||||
formatTimeline(
|
||||
container.read(channelMessagesProvider(_channelId)).requireValue,
|
||||
)
|
||||
.singleWhere((message) => message.id == joinedId)
|
||||
.reactions
|
||||
.map((reaction) => reaction.emoji),
|
||||
['🔥'],
|
||||
);
|
||||
});
|
||||
|
||||
test('notice reactions are read past one page', () async {
|
||||
@@ -723,54 +768,102 @@ void main() {
|
||||
expect(joined.reactions.single.count, 500);
|
||||
});
|
||||
|
||||
test(
|
||||
'a page that loads during a failed notice read gets its notices',
|
||||
() async {
|
||||
const id = _paddedId;
|
||||
for (final rowId in [_paddedId('50'), 'z-row']) {
|
||||
test('an empty notice range stops at its message floor: $rowId', () async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[
|
||||
_event(id: id('a100'), createdAt: 100),
|
||||
_bounds(hasMore: true, cursorCreatedAt: 100, cursorId: id('a100')),
|
||||
],
|
||||
[
|
||||
_event(id: id('a10'), createdAt: 10),
|
||||
_bounds(dTag: '${_channelId.toLowerCase()}:100:${id('a100')}'),
|
||||
_event(id: rowId, createdAt: 50),
|
||||
_bounds(
|
||||
hasMore: true,
|
||||
cursorCreatedAt: 50,
|
||||
cursorId: _paddedId('50'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
final firstRead = Completer<List<NostrEvent>>();
|
||||
relaySession.noticeResults.addAll([
|
||||
firstRead.future,
|
||||
[_joinNotice(id('n50'), 50)],
|
||||
<NostrEvent>[],
|
||||
// Bound a broken implementation too, so a repeated empty-range read
|
||||
// fails the assertion instead of exhausting the test process's heap.
|
||||
Exception('unexpected repeated notice range'),
|
||||
]);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(1));
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
expect(await notifier.fetchOlder(), isTrue);
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(1));
|
||||
|
||||
firstRead.completeError(Exception('notices unavailable'));
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(2));
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value!
|
||||
.map((event) => event.id),
|
||||
[id('a10'), id('n50'), id('a100')],
|
||||
.requireValue
|
||||
.map((e) => e.id),
|
||||
[rowId],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (final fails in [false, true]) {
|
||||
test(
|
||||
'a page that loads during a notice read gets its notices: fails=$fails',
|
||||
() async {
|
||||
const id = _paddedId;
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[
|
||||
_event(id: id('a100'), createdAt: 100),
|
||||
_bounds(
|
||||
hasMore: true,
|
||||
cursorCreatedAt: 100,
|
||||
cursorId: id('a100'),
|
||||
),
|
||||
],
|
||||
[
|
||||
_event(id: id('a10'), createdAt: 10),
|
||||
_bounds(dTag: '${_channelId.toLowerCase()}:100:${id('a100')}'),
|
||||
],
|
||||
],
|
||||
);
|
||||
final firstRead = Completer<List<NostrEvent>>();
|
||||
relaySession.noticeResults.addAll([
|
||||
firstRead.future,
|
||||
[_joinNotice(id('n50'), 50)],
|
||||
]);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(1));
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
expect(await notifier.fetchOlder(), isTrue);
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(1));
|
||||
|
||||
if (fails) {
|
||||
firstRead.completeError(Exception('notices unavailable'));
|
||||
} else {
|
||||
firstRead.complete([]);
|
||||
}
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(2));
|
||||
expect(relaySession.noticeFilters.first.since, 100);
|
||||
expect(relaySession.noticeFilters.last.since, isNull);
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value!
|
||||
.map((event) => event.id),
|
||||
[id('a10'), id('n50'), id('a100')],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('the notice descent stops at its cap', () async {
|
||||
const id = _paddedId;
|
||||
@@ -804,6 +897,97 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
for (final overlaps in [false, true]) {
|
||||
test(
|
||||
'mixed notice ranges respect the unique cap: overlaps=$overlaps',
|
||||
() async {
|
||||
const id = _paddedId;
|
||||
final shortPages = [
|
||||
for (var page = 0; page < 4; page++)
|
||||
[
|
||||
for (var i = 0; i < 499; i++)
|
||||
_joinNotice(
|
||||
id('p$page-${i.toString().padLeft(3, '0')}'),
|
||||
page == 3 ? 2000 : 6000 - page * 1000 - i,
|
||||
),
|
||||
],
|
||||
];
|
||||
final tail = [
|
||||
for (var i = 0; i < (overlaps ? 498 : 500); i++)
|
||||
_joinNotice(id('tail-${i.toString().padLeft(3, '0')}'), 1999 - i),
|
||||
];
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
for (var page = 0; page < 6; page++)
|
||||
[
|
||||
_event(id: id('a$page'), createdAt: 5000 - page * 1000),
|
||||
_bounds(
|
||||
hasMore: page < 5,
|
||||
cursorCreatedAt: 5000 - page * 1000,
|
||||
cursorId: id('a$page'),
|
||||
dTag: page == 0
|
||||
? null
|
||||
: '${_channelId.toLowerCase()}:${6000 - page * 1000}:${id('a${page - 1}')}',
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
relaySession.noticeResults.addAll([
|
||||
...shortPages,
|
||||
[if (overlaps) ...shortPages.last.take(2), ...tail],
|
||||
]);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
for (var page = 1; page < 5; page++) {
|
||||
expect(await notifier.fetchOlder(), isTrue);
|
||||
await _pumpEventQueue();
|
||||
}
|
||||
final retained = container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.requireValue
|
||||
.where((event) => event.kind == EventKind.systemMessage)
|
||||
.toList();
|
||||
expect(retained, hasLength(2000));
|
||||
expect(retained.map((event) => event.id).toSet(), hasLength(2000));
|
||||
expect(
|
||||
retained.map((event) => event.id),
|
||||
containsAll(tail.take(4).map((e) => e.id)),
|
||||
);
|
||||
final discarded = tail.skip(4).map((e) => e.id).toSet();
|
||||
expect(
|
||||
retained.map((event) => event.id),
|
||||
isNot(anyElement(isIn(discarded))),
|
||||
);
|
||||
expect(
|
||||
relaySession.auxFilters.expand(
|
||||
(filter) => filter.tags['#e'] ?? const <String>[],
|
||||
),
|
||||
isNot(anyElement(isIn(discarded))),
|
||||
reason:
|
||||
'excess notices must not fetch reactions or deletion metadata',
|
||||
);
|
||||
final reads = relaySession.noticeFilters.length;
|
||||
expect(await notifier.fetchOlder(), isTrue);
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.noticeFilters, hasLength(reads));
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.requireValue
|
||||
.map((event) => event.id),
|
||||
contains(id('a5')),
|
||||
reason: 'reaching the notice cap must not stop message paging',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('retains historical and live Huddle end events in the window', () async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
|
||||
281
mobile/test/features/channels/channel_notices_journey_test.dart
Normal file
281
mobile/test/features/channels/channel_notices_journey_test.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../helpers/fake_relay.dart';
|
||||
|
||||
const _channelId = '11111111-2222-4333-8444-555555555555';
|
||||
|
||||
// History fixtures cross the fake network boundary; signing hundreds of
|
||||
// fixtures adds no paging coverage. Auth and window bounds still use FakeRelay.
|
||||
NostrEvent _message(FakeRelay relay, int second) => NostrEvent(
|
||||
id: '8${second.toRadixString(16).padLeft(63, '0')}',
|
||||
pubkey: relay.pubkey,
|
||||
kind: EventKind.streamMessage,
|
||||
content: 'Message $second',
|
||||
createdAt: second,
|
||||
sig: '0' * 128,
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
],
|
||||
);
|
||||
|
||||
NostrEvent _notice(FakeRelay relay, int second, String topic) => NostrEvent(
|
||||
id: sha256.convert(utf8.encode('$second:$topic')).toString(),
|
||||
pubkey: relay.pubkey,
|
||||
kind: EventKind.systemMessage,
|
||||
content: jsonEncode({'type': 'topic_changed', 'topic': topic}),
|
||||
createdAt: second,
|
||||
sig: '0' * 128,
|
||||
tags: const [
|
||||
['h', _channelId],
|
||||
],
|
||||
);
|
||||
|
||||
Iterable<Map<String, dynamic>> _noticeReads(FakeRelay relay) => relay.queries
|
||||
.expand((filters) => filters)
|
||||
.where((filter) => (filter['kinds'] as List).singleOrNull == 40099);
|
||||
|
||||
void main() {
|
||||
// The real notifier and session issue HTTP filters; only the network is
|
||||
// fake. The fixture applies since, the composite cursor and the limit, so
|
||||
// removing a production bound or skipping a range changes the result.
|
||||
for (final count in [0, 1, 500, 501]) {
|
||||
test('notice ranges page through $count boundary-second notices', () {
|
||||
fakeAsync((clock) {
|
||||
final relay = FakeRelay();
|
||||
final messages = [for (var i = 1; i <= 151; i++) _message(relay, i)];
|
||||
final boundary = [
|
||||
for (var i = 0; i < count; i++) _notice(relay, 102, 'Boundary $i'),
|
||||
];
|
||||
final burst = [
|
||||
if (count == 501)
|
||||
for (var i = 0; i < 501; i++) _notice(relay, 103, 'Burst $i'),
|
||||
];
|
||||
final middle = _notice(relay, 75, 'Middle');
|
||||
final oldest = _notice(relay, 0, 'Before the first message');
|
||||
relay.seed([...messages, ...burst, ...boundary, middle, oldest]);
|
||||
final container = ProviderContainer(overrides: relay.overrides);
|
||||
addTearDown(container.dispose);
|
||||
final subscription = container.listen(
|
||||
channelMessagesProvider(_channelId),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
void settle() => clock.elapse(const Duration(milliseconds: 50));
|
||||
List<NostrEvent> shown() => subscription.read().requireValue;
|
||||
void checkIds(Iterable<NostrEvent> expected) {
|
||||
expect(
|
||||
shown().map((event) => event.id),
|
||||
unorderedEquals(expected.map((event) => event.id)),
|
||||
);
|
||||
expect(
|
||||
shown().map((event) => event.id).toSet(),
|
||||
hasLength(shown().length),
|
||||
);
|
||||
}
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
void older() {
|
||||
var completed = false;
|
||||
notifier.fetchOlder().then((loaded) {
|
||||
expect(loaded, isTrue);
|
||||
completed = true;
|
||||
});
|
||||
settle();
|
||||
expect(completed, isTrue);
|
||||
}
|
||||
|
||||
settle();
|
||||
expect(_noticeReads(relay).first['since'], 102);
|
||||
if (burst.isNotEmpty) {
|
||||
expect(_noticeReads(relay), hasLength(2));
|
||||
expect(_noticeReads(relay).last['since'], 102);
|
||||
}
|
||||
checkIds([
|
||||
...messages.skip(101),
|
||||
...burst,
|
||||
...boundary.where(
|
||||
(notice) => notice.id.compareTo(messages[101].id) <= 0,
|
||||
),
|
||||
]);
|
||||
|
||||
// A short OR empty bounded response must not finish all history.
|
||||
// The previous boundary's hidden same-second notices now show too.
|
||||
older();
|
||||
checkIds([...messages.skip(51), ...burst, ...boundary, middle]);
|
||||
expect(_noticeReads(relay).last['since'], 52);
|
||||
|
||||
// This range has no notices. It still advances, without rereading
|
||||
// the newest range or abandoning the notices before message history.
|
||||
older();
|
||||
checkIds([...messages.skip(1), ...burst, ...boundary, middle]);
|
||||
expect(_noticeReads(relay).last['since'], 2);
|
||||
older();
|
||||
expect(_noticeReads(relay).last['since'], isNull);
|
||||
checkIds([...messages, ...burst, ...boundary, middle, oldest]);
|
||||
expect(notifier.reachedOldest, isTrue);
|
||||
final reads = relay.queries.length;
|
||||
notifier.fetchOlder().then((loaded) => expect(loaded, isFalse));
|
||||
settle();
|
||||
expect(relay.queries, hasLength(reads));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('same-second message pages reveal cached notices without a gap', () {
|
||||
fakeAsync((clock) {
|
||||
final relay = FakeRelay();
|
||||
final messages = [
|
||||
for (var i = 1; i <= 120; i++)
|
||||
NostrEvent.fromJson({
|
||||
..._message(relay, i).toJson(),
|
||||
'created_at': 50,
|
||||
'id': '${i.toRadixString(16).padLeft(2, '0')}${'0' * 62}',
|
||||
}),
|
||||
];
|
||||
final notice = NostrEvent.fromJson({
|
||||
..._notice(relay, 50, 'Between equal-second messages').toJson(),
|
||||
// Between the 50th (0x32) and 51st (0x33) message in relay order.
|
||||
'id': '328${'0' * 61}',
|
||||
});
|
||||
final beforeMessages = _notice(relay, 0, 'Before all messages');
|
||||
relay.seed([...messages, notice, beforeMessages]);
|
||||
final container = ProviderContainer(overrides: relay.overrides);
|
||||
addTearDown(container.dispose);
|
||||
final subscription = container.listen(
|
||||
channelMessagesProvider(_channelId),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
void settle() => clock.elapse(const Duration(milliseconds: 50));
|
||||
Iterable<String> ids() =>
|
||||
subscription.read().requireValue.map((e) => e.id);
|
||||
settle();
|
||||
expect(ids(), hasLength(50));
|
||||
expect(ids(), isNot(contains(notice.id)));
|
||||
expect(_noticeReads(relay).single['since'], 50);
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
notifier.fetchOlder().then((loaded) => expect(loaded, isTrue));
|
||||
settle();
|
||||
expect(ids(), hasLength(101));
|
||||
expect(ids().where((id) => id == notice.id), hasLength(1));
|
||||
expect(_noticeReads(relay), hasLength(2));
|
||||
expect(_noticeReads(relay).last['since'], 50);
|
||||
notifier.fetchOlder().then((loaded) => expect(loaded, isTrue));
|
||||
settle();
|
||||
expect(ids(), hasLength(122));
|
||||
expect(ids(), contains(beforeMessages.id));
|
||||
expect(_noticeReads(relay).last['since'], isNull);
|
||||
});
|
||||
});
|
||||
|
||||
for (final hasMessage in [false, true]) {
|
||||
test('channel start retains earlier notices: message=$hasMessage', () {
|
||||
fakeAsync((clock) {
|
||||
final relay = FakeRelay();
|
||||
final notice = _notice(relay, 0, 'First activity');
|
||||
relay.seed([if (hasMessage) _message(relay, 10), notice]);
|
||||
final container = ProviderContainer(overrides: relay.overrides);
|
||||
addTearDown(container.dispose);
|
||||
final subscription = container.listen(
|
||||
channelMessagesProvider(_channelId),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
clock.elapse(const Duration(milliseconds: 50));
|
||||
expect(
|
||||
subscription.read().requireValue.map((event) => event.id),
|
||||
contains(notice.id),
|
||||
);
|
||||
expect(_noticeReads(relay).single['since'], isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('scrolling back recovers notices after a failed read and '
|
||||
'interleaves them with older messages', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final relay = FakeRelay();
|
||||
final notice = _notice(relay, 75, 'Older topic');
|
||||
relay.seed([for (var i = 1; i <= 151; i++) _message(relay, i), notice]);
|
||||
relay.failQuery(
|
||||
(filter) => (filter['kinds'] as List).singleOrNull == 40099,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...relay.overrides,
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
appLifecycleProvider.overrideWith(_Resumed.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: ChannelDetailPage(
|
||||
channel: Channel(
|
||||
id: _channelId,
|
||||
name: 'general',
|
||||
description: 'General discussion',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
createdBy: relay.pubkey,
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 1,
|
||||
isMember: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Message 151'), findsOneWidget);
|
||||
expect(find.textContaining('Older topic'), findsNothing);
|
||||
|
||||
// Drive the actual timeline's older-page trigger with scroll gestures.
|
||||
for (
|
||||
var i = 0;
|
||||
i < 30 && find.textContaining('Older topic').evaluate().isEmpty;
|
||||
i++
|
||||
) {
|
||||
await tester.drag(find.byType(Scrollable).first, const Offset(0, 650));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(find.textContaining('Older topic'), findsOneWidget);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(ChannelDetailPage)),
|
||||
);
|
||||
final events = container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.requireValue;
|
||||
final position = events.indexWhere((event) => event.id == notice.id);
|
||||
expect(position, greaterThan(0));
|
||||
expect(events[position - 1].createdAt, lessThanOrEqualTo(75));
|
||||
expect(events[position + 1].createdAt, greaterThanOrEqualTo(75));
|
||||
expect(events.where((event) => event.id == notice.id), hasLength(1));
|
||||
expect(_noticeReads(relay).map((filter) => filter['since']), contains(52));
|
||||
});
|
||||
}
|
||||
|
||||
class _Resumed extends AppLifecycleNotifier {
|
||||
@override
|
||||
AppLifecycleState build() => AppLifecycleState.resumed;
|
||||
}
|
||||
Reference in New Issue
Block a user