mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +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>
243 lines
8.0 KiB
Dart
243 lines
8.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/relay/relay.dart';
|
|
|
|
/// Community-scoped presence: absent means unknown. Polls recover expired leases.
|
|
///
|
|
/// Presence is a best-effort estimate between foreground polls. Per-key
|
|
/// revisions fence stale query completions. Snapshot/live ordering is not
|
|
/// causal: snapshots carry relay time and live events carry signer time. A
|
|
/// live event at/below the relay snapshot second is confirmed by a fresh
|
|
/// snapshot (persistence precedes fan-out). An empty snapshot has no relay
|
|
/// second, so a delayed older live event can briefly supersede its Offline;
|
|
/// the next 60-second poll corrects it.
|
|
class PresenceCacheNotifier extends Notifier<Map<String, String>> {
|
|
/// Bound deduplication memory; redelivery must not re-arm confirmation.
|
|
static const _seenIdLimit = 256;
|
|
|
|
final Set<String> _tracked = {};
|
|
final Set<String> _pending = {};
|
|
final Map<String, int> _revisions = {};
|
|
final Map<String, int> _timestamps = {};
|
|
final Map<String, int> _snapshotAt = {};
|
|
final List<String> _seenIds = [];
|
|
void Function()? _presenceUnsub;
|
|
Timer? _poll;
|
|
Timer? _queued;
|
|
int _generation = 0;
|
|
int _epoch = 0;
|
|
bool _querying = false;
|
|
bool _opening = false;
|
|
bool _retrying = false;
|
|
(String, String?)? _scope;
|
|
|
|
@override
|
|
Map<String, String> build() {
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
final lifecycle = ref.watch(appLifecycleProvider);
|
|
final scope = ref.watch(
|
|
relayConfigProvider.select((config) => (config.baseUrl, config.nsec)),
|
|
);
|
|
if (_scope != scope) _tracked.clear();
|
|
_scope = scope;
|
|
final generation = ++_generation;
|
|
_epoch++;
|
|
_querying = _opening = _retrying = false;
|
|
_revisions.clear();
|
|
_timestamps.clear();
|
|
_snapshotAt.clear();
|
|
_seenIds.clear();
|
|
_pending
|
|
..clear()
|
|
..addAll(_tracked);
|
|
ref.onDispose(() {
|
|
_generation++;
|
|
_presenceUnsub?.call();
|
|
_presenceUnsub = null;
|
|
_poll?.cancel();
|
|
_queued?.cancel();
|
|
_queued = null;
|
|
});
|
|
if (sessionState.status == SessionStatus.connected &&
|
|
lifecycle == AppLifecycleState.resumed) {
|
|
_poll = Timer.periodic(const Duration(seconds: 60), (_) {
|
|
if (_presenceUnsub == null) {
|
|
unawaited(_subscribe(generation));
|
|
} else if (!_querying) {
|
|
_pending.addAll(_tracked);
|
|
_schedule(generation);
|
|
}
|
|
});
|
|
// A transport may report ready synchronously; initialize state first.
|
|
Future.microtask(() => _subscribe(generation));
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/// Request initial presence for exact keys. Repeated render calls coalesce.
|
|
void track(List<String> pubkeys) {
|
|
for (final key in pubkeys.map((key) => key.trim().toLowerCase())) {
|
|
if (key.isNotEmpty && _tracked.add(key)) _pending.add(key);
|
|
}
|
|
_schedule(_generation);
|
|
}
|
|
|
|
void _schedule(int generation) {
|
|
_queued ??= Timer(Duration.zero, () {
|
|
_queued = null;
|
|
if (generation == _generation && _presenceUnsub != null && !_retrying) {
|
|
unawaited(_refresh(generation));
|
|
}
|
|
});
|
|
}
|
|
|
|
void _invalidateSnapshot() {
|
|
_epoch++;
|
|
_querying = false;
|
|
_pending.addAll(_tracked);
|
|
if (state.isNotEmpty) state = {};
|
|
}
|
|
|
|
Future<void> _subscribe(int generation) async {
|
|
if (_opening || generation != _generation) return;
|
|
_opening = true;
|
|
_retrying = false;
|
|
var closed = false;
|
|
try {
|
|
final unsub = await ref
|
|
.read(relaySessionProvider.notifier)
|
|
.subscribeWithStatus(
|
|
const NostrFilter(kinds: [EventKind.presenceUpdate], limit: 0),
|
|
(event) {
|
|
if (generation == _generation && !closed && !_retrying) {
|
|
_handlePresenceEvent(event);
|
|
}
|
|
},
|
|
onStatusChanged: (status) {
|
|
if (generation != _generation || closed) return;
|
|
_retrying = status == RelaySubscriptionStatus.retrying;
|
|
_invalidateSnapshot();
|
|
if (!_retrying) _schedule(generation);
|
|
},
|
|
onClosed: (_) {
|
|
if (generation != _generation || closed) return;
|
|
closed = true;
|
|
_presenceUnsub?.call();
|
|
_presenceUnsub = null;
|
|
_invalidateSnapshot();
|
|
},
|
|
);
|
|
if (generation != _generation || closed) {
|
|
unsub();
|
|
return;
|
|
}
|
|
_presenceUnsub = unsub;
|
|
_schedule(generation);
|
|
} catch (error) {
|
|
if (generation == _generation) {
|
|
_invalidateSnapshot();
|
|
debugPrint('[PresenceCacheNotifier] subscription failed: $error');
|
|
}
|
|
} finally {
|
|
if (generation == _generation) _opening = false;
|
|
}
|
|
}
|
|
|
|
void _handlePresenceEvent(NostrEvent event) {
|
|
final pubkey = event.pubkey.toLowerCase();
|
|
final status = event.content.trim();
|
|
if (event.kind != EventKind.presenceUpdate ||
|
|
!_tracked.contains(pubkey) ||
|
|
!_validStatus(status) ||
|
|
event.createdAt < (_timestamps[pubkey] ?? 0) ||
|
|
_seenRecently(event.id)) {
|
|
return;
|
|
}
|
|
// Accepted evidence fences in-flight queries, even if it needs confirmation.
|
|
_revisions[pubkey] = (_revisions[pubkey] ?? 0) + 1;
|
|
// Equality cannot order events within the relay's observation second.
|
|
if (event.createdAt <= (_snapshotAt[pubkey] ?? 0)) {
|
|
_pending.add(pubkey);
|
|
_schedule(_generation);
|
|
return;
|
|
}
|
|
_timestamps[pubkey] = event.createdAt;
|
|
if (state[pubkey] != status) state = {...state, pubkey: status};
|
|
}
|
|
|
|
Future<void> _refresh(int generation) async {
|
|
if (_querying || _pending.isEmpty) return;
|
|
_querying = true;
|
|
final epoch = _epoch;
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
while (_pending.isNotEmpty) {
|
|
final keys = _pending.take(100).toList();
|
|
_pending.removeAll(keys);
|
|
final revisions = {for (final key in keys) key: _revisions[key] ?? 0};
|
|
List<NostrEvent>? events;
|
|
try {
|
|
events = await session.queryRelay([
|
|
NostrFilter(
|
|
kinds: [EventKind.presenceUpdate],
|
|
authors: keys,
|
|
limit: keys.length,
|
|
),
|
|
]);
|
|
} catch (error) {
|
|
debugPrint('[PresenceCacheNotifier] snapshot failed: $error');
|
|
}
|
|
if (generation != _generation || epoch != _epoch) return;
|
|
final latest = <String, NostrEvent>{};
|
|
var observedAt = 0;
|
|
for (final event in events ?? <NostrEvent>[]) {
|
|
final key = (event.getTagValue('p') ?? event.pubkey).toLowerCase();
|
|
if (!revisions.containsKey(key) ||
|
|
event.kind != EventKind.presenceUpdate ||
|
|
!_validStatus(event.content.trim())) {
|
|
continue;
|
|
}
|
|
if (!latest.containsKey(key) ||
|
|
event.createdAt > latest[key]!.createdAt) {
|
|
latest[key] = event;
|
|
}
|
|
if (event.createdAt > observedAt) observedAt = event.createdAt;
|
|
}
|
|
final updated = {...state};
|
|
for (final key in keys) {
|
|
if ((_revisions[key] ?? 0) != revisions[key]) continue;
|
|
if (events == null) {
|
|
updated.remove(key); // failure is unknown; next poll retries
|
|
} else {
|
|
// Shared synthesis clock fences the batch; empty answers retain it.
|
|
if (observedAt > (_snapshotAt[key] ?? 0)) {
|
|
_snapshotAt[key] = observedAt;
|
|
}
|
|
updated[key] = latest[key]?.content.trim() ?? 'offline';
|
|
}
|
|
}
|
|
if (!mapEquals(state, updated)) state = updated;
|
|
}
|
|
_querying = false;
|
|
}
|
|
|
|
bool _seenRecently(String id) {
|
|
if (id.isEmpty) return false;
|
|
if (_seenIds.contains(id)) return true;
|
|
_seenIds.add(id);
|
|
if (_seenIds.length > _seenIdLimit) _seenIds.removeAt(0);
|
|
return false;
|
|
}
|
|
|
|
bool _validStatus(String status) =>
|
|
status == 'online' || status == 'away' || status == 'offline';
|
|
}
|
|
|
|
final presenceCacheProvider =
|
|
NotifierProvider<PresenceCacheNotifier, Map<String, String>>(
|
|
PresenceCacheNotifier.new,
|
|
);
|