mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and Figgies every five seconds, without creating video players. The next poster is precached; reduced motion and hidden-screen pauses are preserved. The launcher uses the neutral “Bestie” label until a selection is available, then “Chat with …”. For supported communities with no owned agents, tapping opens Create agent directly. A successful first-agent setup saves that agent as Bestie without replacing an existing preference; existing-agent and unsupported-community paths keep the Agents list. Verified Builderlab registrations can show sleeping remote agents as Online. Owner-scoped saved runtime availability now prevents a known absent or deleted runtime from returning Online after Builderlab sign-out or a fresh load, even when another community retains its registration. Validation: mobile static checks; 4,274 Flutter tests passed with hosted flows enabled; launcher → cancel → create → Home “Chat with Scout”, including encrypted persisted selection; sign-out, fresh-load, owner isolation and deletion regressions. Restoring the old carousel produced three video-player requests; removing the saved-availability guard reproduced incorrect Online after sign-out. The earlier iPhone recording below shows the original carousel and entry flow and stops before creation. The new poster-only behavior and completed creation are covered by widget tests, not a new device recording. https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
276 lines
9.6 KiB
Dart
276 lines
9.6 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/agents/remote_agents.dart';
|
|
import '../../shared/agents/owned_agent_directory.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,
|
|
);
|
|
|
|
/// Presentation shared by profiles, DMs and mentions. Remote agents wake on
|
|
/// messages. Their verified Builderlab runtime registration is enough to show
|
|
/// Online when the account API is unavailable or signed out, unless the owner
|
|
/// has a saved registry snapshot that excludes it. The registry also supports
|
|
/// older agents without runtime metadata. Local presence is unchanged.
|
|
final presentedPresenceProvider = Provider<Map<String, String>>((ref) {
|
|
final presence = ref.watch(presenceCacheProvider);
|
|
final directoryState = ref.watch(ownedAgentDirectoryProvider);
|
|
final directory = directoryState.isLoading || directoryState.hasError
|
|
? null
|
|
: directoryState.asData?.value;
|
|
final controller = ref.watch(remoteAgentControllerProvider);
|
|
void changed() => ref.invalidateSelf();
|
|
controller.addListener(changed);
|
|
ref.onDispose(() => controller.removeListener(changed));
|
|
final saved = ref.watch(savedRemoteAvailabilityProvider);
|
|
final remote = {
|
|
...?ref.watch(availableRemoteAgentsProvider).value,
|
|
if (!controller.registryLoaded &&
|
|
!saved.isLoading &&
|
|
!saved.hasError &&
|
|
saved.hasValue)
|
|
for (final agent in directory ?? <OwnedAgentDirectoryEntry>[])
|
|
if (agent.runtime == 'block.builderlab/builderlab' &&
|
|
(saved.value == null || saved.value!.contains(agent.pubkey)))
|
|
agent.pubkey,
|
|
}..removeAll(controller.deletedAgents);
|
|
if (remote.isEmpty) return presence;
|
|
return {...presence, for (final pubkey in remote) pubkey: 'online'};
|
|
});
|