mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user