Files
buzz/mobile/lib/features/profile/presence_cache_provider.dart
Logan Johnson 965fe1dffa fix(mobile): hydrate and refresh relay presence snapshots (#7382)
<!-- 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 | ![Before: the profile stays Offline — the snapshot is never
fetched](https://github.com/user-attachments/assets/4b4899c8-364c-4621-8d59-949189860eb3)
| ![After: the profile shows Online once the snapshot
hydrates](https://github.com/user-attachments/assets/7b1a9ecd-a7a8-4e04-b007-b86565a39cfc)
|

<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>
2026-09-30 20:10:24 -04:00

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,
);