perf(mobile): batch initial channel read-state seeds (#8607)

## Summary

Opening an uncached community seeded each previously unseen channel's
read marker separately. Every seed copied and serialized the full
read-state snapshot, started three preference writes, and notified
listeners. Home now collects those seeds and saves/emits once, using the
existing monotonic marker admission rules.

For a 400-channel fixture with one existing marker, initial seeding
falls from 399 full snapshot saves to one. Existing unread frontiers,
local-only seeds and explicit read publishing keep their behavior. No
transport, cache, relay or unread eligibility changes. This is
independent of #8580 and #8490.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,779
tests passed, 19 skipped, plus four checks for builds without push
configuration.
- Real Home/channel-provider/read-state journey covers 400 channels,
preservation of an existing unread highlight, persisted marks, no seed
publication, and no repeated seed save on refresh. It replaces the
earlier fake-notifier seed test.
- Restoring the old per-channel loop makes the new journey fail at the
single-snapshot assertion.
- Diff and file-size checks pass.
- Merged current main (`209fcd217`) at `d34d29627`; retained both the
cold read-seeding and cached-community landing test registrations. The
production diff is byte-for-byte unchanged from the version reviewed by
the Court at `6a232ed37`. The complete mobile gates above passed again
on the merged tree.

Device latency is unmeasured. The Court source review found no blockers
at `6a232ed37`; app/device testing and human acceptance remain
unconfirmed. To try: open a community without a saved channel list,
confirm its channels load and existing unread highlights remain correct,
then refresh and reopen it. The performance claim here is reduced save
work, not a measured end-to-end speedup.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-10-26 12:36:23 -07:00
committed by GitHub
parent 209fcd2172
commit 8ceca8ac46
6 changed files with 221 additions and 61 deletions

View File

@@ -168,7 +168,7 @@ class _SliverChannelsList extends HookConsumerWidget {
return;
}
final notifier = ref.read(readStateProvider.notifier);
final seeds = <String, int>{};
for (final channel in visibleChannels) {
if (readState.effectiveTimestamp(channel.id) != null) {
continue;
@@ -176,9 +176,10 @@ class _SliverChannelsList extends HookConsumerWidget {
final lastMessageAt = dateTimeToUnixSeconds(channel.lastMessageAt);
if (lastMessageAt != null) {
notifier.seedContextRead(channel.id, lastMessageAt);
seeds[channel.id] = lastMessageAt;
}
}
ref.read(readStateProvider.notifier).seedContextsRead(seeds);
initialSeedComplete.value = true;
});
}, [readState.isReady, readState.pubkey, visibleChannels]);

View File

@@ -131,11 +131,28 @@ class ReadStateManager {
currentUnixSeconds(),
_maxFetchedCreatedAt + 1,
);
_advanceContext(contextId, unixTimestamp, publishable: true);
if (_advanceContext(contextId, unixTimestamp, publishable: true)) {
_persistLocalState();
_onChanged();
_schedulePublish();
}
}
void seedContextRead(String contextId, int unixTimestamp) {
_advanceContext(contextId, unixTimestamp, publishable: false);
seedContextsRead({contextId: unixTimestamp});
}
/// Advance local-only seeds together, saving and notifying once per batch.
void seedContextsRead(Map<String, int> contexts) {
var changed = false;
for (final entry in contexts.entries) {
if (_advanceContext(entry.key, entry.value, publishable: false)) {
changed = true;
}
}
if (!changed) return;
_persistLocalState();
_onChanged();
}
Future<void> flush() async {
@@ -180,35 +197,28 @@ class ReadStateManager {
_unsubscribeLive = null;
}
void _advanceContext(
bool _advanceContext(
String contextId,
int unixTimestamp, {
required bool publishable,
}) {
if (_disposed || unixTimestamp < 0) return;
if (_disposed || unixTimestamp < 0) return false;
final current = _effectiveState[contextId] ?? 0;
if (unixTimestamp <= current) {
if (!publishable || _publishableContextIds.contains(contextId)) {
return;
return false;
}
_publishableContextIds.add(contextId);
_persistLocalState();
_onChanged();
_schedulePublish();
return;
return true;
}
_effectiveState[contextId] = unixTimestamp;
if (publishable) {
_publishableContextIds.add(contextId);
}
_persistLocalState();
_onChanged();
if (publishable) {
_schedulePublish();
}
return true;
}
Future<void> _fetchAndMerge() async {

View File

@@ -200,6 +200,11 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
_manager?.seedContextRead(contextId, unixTimestamp);
}
/// Seed a channel-list snapshot with one local save and state emission.
void seedContextsRead(Map<String, int> contexts) {
_manager?.seedContextsRead(contexts);
}
void _emitManagerState(ReadStateManager manager) {
if (_manager != manager) return;
final advances = manager.drainSyncedAdvances();

View File

@@ -1,3 +1,7 @@
import 'dart:convert';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import 'package:buzz/shared/read_state/read_state_storage.dart';
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/shared/widgets/app_route_observer.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
@@ -62,6 +66,7 @@ import '../../helpers/thread_ids.dart';
part 'channels_page_test/presence_tests.dart';
part 'channels_page_test/dm_recipient_layout_tests.dart';
part 'channels_page_test/unread_readiness_tests.dart';
part 'channels_page_test/read_seed_journey.dart';
void main() {
setUpAll(() async {
@@ -159,6 +164,7 @@ void main() {
}
_communityUnreadLandingTests(buildTestable);
_readSeedJourney(buildTestable);
_communityCachedLandingTests(buildTestable);
final testChannels = [
@@ -3717,51 +3723,6 @@ void main() {
});
}
testWidgets('seeds first loaded channels as read', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
expect(
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w400,
);
});
testWidgets('waits for read-state readiness before initial seeding', (
tester,
) async {
@@ -3996,6 +3957,13 @@ class _FakeReadStateNotifier extends ReadStateNotifier {
);
}
@override
void seedContextsRead(Map<String, int> contexts) {
for (final entry in contexts.entries) {
seedContextRead(entry.key, entry.value);
}
}
@override
void seedContextRead(String contextId, int unixTimestamp) {
seededContexts[contextId] = unixTimestamp;

View File

@@ -0,0 +1,169 @@
part of '../channels_page_test.dart';
void _readSeedJourney(
Widget Function({required List<Override> overrides}) buildTestable,
) {
testWidgets('cold community seeds read state with one saved snapshot', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final store = _ReadSeedStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ReadSeedSession(keys.public);
final readKey = localReadStateKey(keys.public, 'https://seed.example');
// Preserve a real pre-existing unread frontier while seeding new channels.
await prefs.setString(
readKey,
jsonEncode({
'channel-0': DateTime.fromMillisecondsSinceEpoch(
10000,
isUtc: true,
).toIso8601String(),
}),
);
store.nonemptySnapshots.clear();
await tester.pumpWidget(
buildTestable(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
relayConfigProvider.overrideWith(() => _ReadSeedConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
activeCommunityProvider.overrideWith((ref) async => null),
],
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ChannelsPage)),
);
final reads = container.read(readStateProvider);
expect(reads.contexts, {
'channel-0': 10,
for (var i = 1; i < 400; i++) 'channel-$i': 20,
});
// Ignore hydration saves of the existing mark; landing saves all seeds together.
expect(store.nonemptySnapshots.where((count) => count > 1), [400]);
expect(
tester.widget<Text>(find.text('channel-0')).style?.fontWeight,
FontWeight.w700,
);
expect(
tester.widget<Text>(find.text('channel-1')).style?.fontWeight,
FontWeight.w400,
);
final saved = ReadStateStorage(
prefs,
).read(keys.public, 'https://seed.example');
expect(saved.contexts, reads.contexts);
expect(saved.publishableContextIds, isEmpty);
await tester.pump(const Duration(seconds: 6));
expect(session.readStatePublishes, 0);
// A refresh/rebuild does not re-seed or save unchanged marks.
await container.read(channelsProvider.notifier).refresh();
await tester.pumpAndSettle();
expect(store.nonemptySnapshots.where((count) => count > 1), [400]);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
class _ReadSeedStore extends InMemorySharedPreferencesStore {
_ReadSeedStore() : super.empty();
final nonemptySnapshots = <int>[];
@override
Future<bool> setValue(String valueType, String key, Object value) async {
if (key.contains('buzz.channel-read-state.v2:')) {
final contexts = jsonDecode(value as String) as Map;
if (contexts.isNotEmpty) nonemptySnapshots.add(contexts.length);
}
return super.setValue(valueType, key, value);
}
}
class _ReadSeedConfig extends RelayConfigNotifier {
_ReadSeedConfig(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://seed.example', nsec: nsec);
}
class _ReadSeedSession extends RelaySessionNotifier {
_ReadSeedSession(this.pubkey);
final String pubkey;
int readStatePublishes = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
NostrEvent event(int kind, String channel, List<List<String>> tags) =>
NostrEvent(
id: '$kind-$channel',
pubkey: kind == 9 ? 'other-member' : pubkey,
createdAt: 20,
kind: kind,
tags: tags,
content: '',
sig: '',
);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
final filter = filters.first;
if (filter.kinds.contains(39002)) {
if (filter.until != null) return [];
return [
for (var i = 0; i < 400; i++)
event(39002, 'channel-$i', [
['d', 'channel-$i'],
['p', pubkey],
]),
];
}
return [
for (final f in filters)
if (f.tags['#h'] != null)
for (final id in f.tags['#h']!)
event(9, id, [
['h', id],
]),
];
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => filter.kinds.contains(39000)
? [
for (var i = 0; i < 400; i++)
event(39000, 'channel-$i', [
['d', 'channel-$i'],
['name', 'channel-$i'],
['t', 'stream'],
['public'],
]),
]
: [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async => () {};
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (event.kind == EventKind.readState) readStatePublishes++;
return event;
}
}

View File

@@ -73,6 +73,13 @@ class _Reads extends ReadStateNotifier {
final bool empty;
final bool ready;
_Reads({this.empty = false, this.ready = true});
@override
void seedContextsRead(Map<String, int> contexts) {
for (final entry in contexts.entries) {
seedContextRead(entry.key, entry.value);
}
}
@override
void seedContextRead(String key, int timestamp) => read(key, timestamp);
@override