mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
<!-- Draft PR body for #7382 · fix(mobile): hydrate and refresh relay presence snapshots · https://github.com/block/buzz/pull/7382 --> 🤖 ## Summary Mobile showed every person in a DM as **Offline** until they happened to publish a presence update while the app was connected. Presence was never loaded when the app opened, never refreshed, and a missing or failed read looked identical to actually being offline. This PR makes presence load and stay current: on connect or foreground, the app fetches the relay's presence snapshot for everyone visible, keeps the live subscription open while it does, and re-checks every 60 seconds while the app is in the foreground. A missing or failed read shows **Unknown** (no dot), not Offline. - Snapshot reads are authenticated like the rest of the session and batched by exact key (at most 100 per batch), instead of relying on live events only. - The live subscription opens before the snapshot, so updates published during the read are not dropped. Per-key revisions fence stale **query completions**: a snapshot that finishes after newer live evidence for that key does not overwrite it. - Refreshing every 60 foreground seconds observes relay lease expiry and retry failures; missing or failed evidence simply stays absent — it is never reported as Offline. - Disconnects, backgrounding, and account/community changes reset the cache cleanly. The app's existing subscription/recovery machinery is reused rather than duplicated. Mirrors Desktop's presence hydration (`desktop/src-tauri/src/commands/profile.rs::get_presence`, `desktop/src/features/presence/hooks.ts`). ### Presence contract: best effort between polls Presence is a best-effort estimate that the next foreground poll corrects. This PR does not give strict causal order between snapshots and live events: - Snapshots carry relay time. Live kind:20001 events carry the signer's time. These clocks are not comparable. - A live event at or below a snapshot's relay second is not applied directly. The client confirms it with a fresh snapshot. This works because the relay persists presence before fan-out. - An empty (all-Offline) snapshot carries no relay second. If the relay stored an Online event, then its fan-out was delayed past lease expiry and an empty snapshot, the delayed event can show Online/Away until the next 60-second poll. Base `main` has the same stale-delivery exposure with no correction at all. Strict ordering needs relay-issued ordering metadata on both paths, or a fresh confirmation for every live event. Both are protocol or latency tradeoffs and are out of scope here. ### Related issue - Fixes: N/A. Closest open duplicates: #4417 (larger presence bootstrap + UI patch), #5617. Related: #6200, #7295. - First slice of the mobile remote-agent parity port. This PR includes the Unknown rendering at all three consumers (DM header, channel tile, profile sheet). #7383 and #7526 stack on it and add test coverage only. ### Testing - Rebased on `main` `95b018c87` (2026-09-30). At stack tip `7c8a93081`: `just mobile-check` clean; full `just mobile-test` **2,593 passed**. Earlier receipts below apply to the pre-rebase head. - Regressions cover cold-open hydration, live/snapshot races, lease expiry, failed reads and retries, disconnect/background/scope changes, and >100-key batching. - At the branch head: `just mobile-check` and the full mobile test suite pass independently, and every broader CI gate has successful evidence after isolated recovery runs (no monolithic local `just ci` run) — receipts in the [exact-head evidence comment](https://github.com/block/buzz/pull/7382#issuecomment-5573112882). - Verification is automated-test level; no native device or simulator run is claimed. No layout change in this slice. ### Screenshots No layout change in this slice — the renders below show the same existing profile sheet, with the presence status now following hydrated relay data. Flutter production-widget test renders — not native-device screenshots or acceptance captures. | Scenario | Before | After | |---|---|---| | Existing profile opened cold, with a signed relay snapshot saying Online |  |  | <details> <summary>Capture provenance</summary> Rendered by the Flutter widget engine in a `flutter test` run (production widgets, production theme; no device or simulator). Before: this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`. After: its head `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`. </details> Signed-off-by: Logan Johnson <loganj@squareup.com>
196 lines
6.6 KiB
Dart
196 lines
6.6 KiB
Dart
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/features/profile/presence_cache_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
|
|
/// Live-stream behavior alongside the authenticated snapshot backstop.
|
|
void main() {
|
|
test('WS presence event updates cache for tracked pubkey', () async {
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
// Initialize the notifier (triggers build → subscribes to WS).
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
// Track alice, then emit her initial 'online' status.
|
|
container.read(presenceCacheProvider.notifier).track(['alice']);
|
|
relaySession.emit(_presence('alice', 'online'));
|
|
expect(container.read(presenceCacheProvider)['alice'], 'online');
|
|
|
|
// Simulate a WS presence event: alice goes away.
|
|
relaySession.emit(_presence('alice', 'away'));
|
|
expect(container.read(presenceCacheProvider)['alice'], 'away');
|
|
});
|
|
|
|
test('WS presence event ignores untracked pubkeys', () async {
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
// Track only alice.
|
|
container.read(presenceCacheProvider.notifier).track(['alice']);
|
|
|
|
// Emit event for bob (untracked).
|
|
relaySession.emit(_presence('bob', 'online'));
|
|
|
|
// Bob should NOT appear in the cache.
|
|
expect(container.read(presenceCacheProvider).containsKey('bob'), isFalse);
|
|
});
|
|
|
|
test('WS presence event ignores invalid status values', () async {
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
container.read(presenceCacheProvider.notifier).track(['alice']);
|
|
relaySession.emit(_presence('alice', 'online'));
|
|
expect(container.read(presenceCacheProvider)['alice'], 'online');
|
|
|
|
// Emit event with garbage status — should be rejected.
|
|
relaySession.emit(_presence('alice', 'garbage-status'));
|
|
|
|
// Status should remain 'online'.
|
|
expect(container.read(presenceCacheProvider)['alice'], 'online');
|
|
});
|
|
|
|
test('WS presence event skips no-op updates', () async {
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
container.read(presenceCacheProvider.notifier).track(['alice']);
|
|
relaySession.emit(_presence('alice', 'online'));
|
|
|
|
// Listen for state changes after initial setup.
|
|
var stateChangeCount = 0;
|
|
container.listen(presenceCacheProvider, (prev, next) => stateChangeCount++);
|
|
|
|
// Emit event with same status as current.
|
|
relaySession.emit(_presence('alice', 'online'));
|
|
|
|
// No state change should occur — it's a no-op.
|
|
expect(stateChangeCount, 0);
|
|
});
|
|
|
|
test('subscribes to kind:20001 with limit 0', () async {
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
// Should have subscribed with the correct filter.
|
|
expect(relaySession.filters, hasLength(1));
|
|
expect(relaySession.filters.single.kinds, [EventKind.presenceUpdate]);
|
|
expect(relaySession.filters.single.limit, 0);
|
|
});
|
|
|
|
test('WS event uses pubkey variable, not literal string', () async {
|
|
// Regression test for the map key bug where `{...state, pubkey: status}`
|
|
// used the literal string "pubkey" instead of the variable's value.
|
|
final relaySession = _RecordingRelaySessionNotifier();
|
|
final container = _buildContainer(relaySession: relaySession);
|
|
addTearDown(container.dispose);
|
|
|
|
container.read(presenceCacheProvider);
|
|
await _pumpEventQueue();
|
|
|
|
container.read(presenceCacheProvider.notifier).track([
|
|
'deadbeef',
|
|
'cafebabe',
|
|
]);
|
|
|
|
// Seed cafebabe -> offline, then set deadbeef online.
|
|
relaySession.emit(_presence('cafebabe', 'offline'));
|
|
relaySession.emit(_presence('deadbeef', 'online'));
|
|
|
|
final cache = container.read(presenceCacheProvider);
|
|
// deadbeef should be online (the actual pubkey, not a literal "pubkey" key).
|
|
expect(cache['deadbeef'], 'online');
|
|
// cafebabe should still be offline (not clobbered).
|
|
expect(cache['cafebabe'], 'offline');
|
|
// There should be no literal "pubkey" key in the map.
|
|
expect(cache.containsKey('pubkey'), isFalse);
|
|
});
|
|
}
|
|
|
|
NostrEvent _presence(String pubkey, String status) => NostrEvent(
|
|
id: 'evt-$pubkey-$status',
|
|
pubkey: pubkey,
|
|
createdAt: 1000,
|
|
kind: EventKind.presenceUpdate,
|
|
tags: const [],
|
|
content: status,
|
|
sig: 'sig',
|
|
);
|
|
|
|
Future<void> _pumpEventQueue() async {
|
|
await Future<void>.delayed(Duration.zero);
|
|
await Future<void>.delayed(Duration.zero);
|
|
}
|
|
|
|
ProviderContainer _buildContainer({
|
|
required _RecordingRelaySessionNotifier relaySession,
|
|
}) {
|
|
return ProviderContainer(
|
|
overrides: [
|
|
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
|
|
relaySessionProvider.overrideWith(() => relaySession),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
|
final List<NostrFilter> filters = [];
|
|
final List<void Function(NostrEvent)> _listeners = [];
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<void Function()> subscribeWithStatus(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
required void Function(RelaySubscriptionStatus) onStatusChanged,
|
|
}) async {
|
|
filters.add(filter);
|
|
_listeners.add(onEvent);
|
|
return () {
|
|
filters.remove(filter);
|
|
_listeners.remove(onEvent);
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async => [];
|
|
|
|
/// Emit an event synchronously to all live subscribers.
|
|
void emit(NostrEvent event) {
|
|
for (final listener in List.of(_listeners)) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _FakeAppLifecycleNotifier extends AppLifecycleNotifier {
|
|
@override
|
|
AppLifecycleState build() => AppLifecycleState.resumed;
|
|
}
|