mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Summary Returning to a previously visited community shows its saved channel list immediately, including offline, while membership and unread state refresh in the background. Initial revalidation failures keep the list visible and retry every 60 seconds without overlapping background attempts. After successful history revalidation, ordinary reconnects use metadata-only refreshes. A reconnect arriving during a background request queues one follow-up attempt; timer ticks never queue overlapping work. History deadlines retain the full-revalidation retry until history succeeds. Snapshots are disposable, versioned and scoped by canonical relay URL and signing identity, capped at 2 MB / 2,000 member channels. They include hidden-DM IDs and prefer fresher in-memory state. Community removal and identity replacement retire outstanding writes; re-adding unchanged credentials preserves the current cache and writer. Startup sweeps orphan snapshots without waiting for disk acknowledgement; community mutations also sweep orphan snapshots, including data restored without secure community records. Cache-delete failures do not block sign-out or strand push revocation; deletion is attempted before journaling and retried by orphan cleanup. Successfully persisted unchanged snapshots skip disk writes; failed writes remain retryable. The cold path reuses fetched membership rosters, overlaps profile/latest-message requests, shares verified DM profiles with avatars, and fetches the own profile once per authenticated connection. Community connection work overlaps the switching animation; avatar hydration no longer gates landing. Full channel refreshes also revalidate cached DM participant profiles so relay-side name changes reach labels and avatars; metadata-only reconnects and background refreshes reuse admitted profiles. Me-chat and invite starter recovery use an explicit fresh membership fetch that propagates errors while leaving the cached list visible. Cached layout/read preferences permit immediate landing; newer cross-device preferences may update ordering or badges after reveal. Integrated with main’s #8490 idle/resume changes: hidden chats detach live subscriptions, background membership polling stays quiet, and each resume reconciles once. Chat history follows main’s live-admission-first ordering to avoid missing messages committed during relay admission; cached community landing remains immediate. ## Validation - `just mobile-install mobile-check mobile-test`: clean analysis; 3,778 tests passed, 19 skipped; four checks for builds without push configuration passed. File-size and diff checks passed. - Provider/storage journeys cover offline startup, failed initial revalidation recovering live delivery, bounded retries, lightweight reconnects, scope isolation, default-port URLs, padded/mismatched stored credentials, credential replacement, backup-restored orphan data, removal races, failed platform deletes and failed writes. - Follow-up regressions cover interrupted native Me-chat lookup, invite failure propagation, retry after an initial list failure, history deadlines, queued reconnects, escaped-path keys, unchanged credential re-adds and non-blocking startup cleanup. Reverting each of the eight behavior/caller/cleanup-order fixes fails its corresponding regression. - DM-name regression changes only the relay profile, verifies a full refresh updates the channel label and shared profile cache, and confirms ordinary reconnects avoid another profile request. The original cache-only implementation fails this regression. - Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's 413 channels went from 6.26–6.30 s to approximately 1.5 s on returning; all three warmed communities switched in 1.47–1.54 s. Real socket pause restored the cached list offline and retained it on reconnect. These are debug simulator observations, not release-device benchmarks. - Integration with current main also passes the real-session resume/background/AUTH and live-admission/detach journeys. - Follow-up failure paths were exercised with deterministic provider/storage tests and widget journeys; the running simulator was not modified during this follow-up. Human acceptance remains pending; this PR stays draft. Try switching among previously visited communities, opening a channel, restarting and repeating. First visits and oversized snapshots require network loading. Cached metadata can be stale until refresh; it lives in ordinary preferences without additional application-level encryption. The snapshot contains no message bodies or credentials. Persistent disk-deletion failures can leave metadata until a later cleanup succeeds. --------- Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz> Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz> Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz> Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
443 lines
14 KiB
Dart
443 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/crypto/nip_oa.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import '../../shared/profile/user_profile.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
|
|
/// Signals that a profile write no longer belongs to the active community.
|
|
class ProfileCommunityChangedException extends StateError {
|
|
/// Creates an error for a profile write invalidated by a community switch.
|
|
ProfileCommunityChangedException()
|
|
: super('Profile update cancelled because the active community changed.');
|
|
}
|
|
|
|
/// Keeps a freshly saved animated avatar local until its remote first frame is
|
|
/// ready, preserving the editor-to-profile visual handoff.
|
|
@immutable
|
|
class ProfileAvatarHandoff {
|
|
/// Creates a local handoff for [avatarUrl].
|
|
const ProfileAvatarHandoff({
|
|
required this.avatarUrl,
|
|
required this.animation,
|
|
required this.poster,
|
|
});
|
|
|
|
/// The persisted animated-avatar descriptor this handoff belongs to.
|
|
final String avatarUrl;
|
|
|
|
/// The locally encoded animation shown while remote media warms up.
|
|
final Uint8List animation;
|
|
|
|
/// The locally encoded poster used when motion is disabled.
|
|
final Uint8List poster;
|
|
}
|
|
|
|
/// Owns the temporary local avatar shown across the save route transition.
|
|
class ProfileAvatarHandoffNotifier extends Notifier<ProfileAvatarHandoff?> {
|
|
@override
|
|
ProfileAvatarHandoff? build() {
|
|
ref.watch(relayConfigProvider);
|
|
return null;
|
|
}
|
|
|
|
/// Starts a handoff for a freshly published animated avatar.
|
|
void show(ProfileAvatarHandoff handoff) => state = handoff;
|
|
|
|
/// Clears the handoff once the matching remote animation is ready.
|
|
void clear(String avatarUrl) {
|
|
if (state?.avatarUrl == avatarUrl) state = null;
|
|
}
|
|
|
|
/// Clears a handoff superseded by a non-animated avatar save.
|
|
void clearAny() => state = null;
|
|
}
|
|
|
|
/// The freshly saved local animated avatar, while its remote copy warms up.
|
|
final profileAvatarHandoffProvider =
|
|
NotifierProvider<ProfileAvatarHandoffNotifier, ProfileAvatarHandoff?>(
|
|
ProfileAvatarHandoffNotifier.new,
|
|
);
|
|
|
|
/// The current user's profile (kind:0 metadata) loaded over the relay
|
|
/// WebSocket. Returns null when no nsec is configured or when the user has
|
|
/// not yet published a profile.
|
|
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
|
Map<String, dynamic> _metadata = {};
|
|
bool _hasHydrated = false;
|
|
int _lastCreatedAt = 0;
|
|
Future<void> _patchQueue = Future.value();
|
|
|
|
@override
|
|
Future<UserProfile?> build() async {
|
|
final config = ref.watch(relayConfigProvider);
|
|
final pubkey = ref.watch(myPubkeyProvider);
|
|
final context = _ProfileWriteContext(
|
|
config: config,
|
|
pubkey: pubkey,
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
);
|
|
_hasHydrated = false;
|
|
if (pubkey != null) {
|
|
final connected = Completer<void>();
|
|
ref.listen(relaySessionProvider.select((value) => value.status), (
|
|
previous,
|
|
next,
|
|
) {
|
|
if (next != SessionStatus.connected) return;
|
|
if (!connected.isCompleted) {
|
|
connected.complete();
|
|
} else if (previous != SessionStatus.connected) {
|
|
// Revalidate once per authenticated connection. Intermediate socket
|
|
// states must not enqueue duplicate profile reads or erase the avatar.
|
|
ref.invalidateSelf();
|
|
}
|
|
});
|
|
if (ref.read(relaySessionProvider).status == SessionStatus.connected) {
|
|
connected.complete();
|
|
}
|
|
await connected.future;
|
|
_requireCurrentWriteContext(context);
|
|
}
|
|
return _fetch(context);
|
|
}
|
|
|
|
Future<UserProfile?> _fetch(_ProfileWriteContext context) async {
|
|
final myPk = context.pubkey;
|
|
if (myPk == null) {
|
|
_requireCurrentWriteContext(context);
|
|
_metadata = {};
|
|
_lastCreatedAt = 0;
|
|
_hasHydrated = true;
|
|
return null;
|
|
}
|
|
|
|
final session = context.session;
|
|
final events = await session.fetchHistory(NostrFilters.profile(myPk));
|
|
if (events.isEmpty) {
|
|
_requireCurrentWriteContext(context);
|
|
_metadata = {};
|
|
_lastCreatedAt = 0;
|
|
_hasHydrated = true;
|
|
return null;
|
|
}
|
|
final latest = _latestProfileEvent(events)!;
|
|
final metadata = _decodeProfileMetadata(latest);
|
|
final data = ProfileData.fromEvent(latest);
|
|
final profile = UserProfile(
|
|
pubkey: data.pubkey,
|
|
displayName: data.displayName,
|
|
avatarUrl: data.avatarUrl,
|
|
about: data.about,
|
|
nip05Handle: data.nip05,
|
|
ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey),
|
|
);
|
|
_requireCurrentWriteContext(context);
|
|
_metadata = metadata;
|
|
_lastCreatedAt = latest.createdAt;
|
|
_hasHydrated = true;
|
|
return profile;
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
final context = _currentWriteContext();
|
|
_hasHydrated = false;
|
|
state = await AsyncValue.guard(() => _fetch(context));
|
|
}
|
|
|
|
/// Updates the current user's display name while preserving the other
|
|
/// metadata fields in their latest kind:0 profile event.
|
|
Future<void> updateSetup(String displayName, String? avatarUrl) =>
|
|
_publishProfilePatch({
|
|
'display_name': displayName.trim(),
|
|
'picture': ?avatarUrl,
|
|
});
|
|
|
|
Future<void> updateDisplayName(String displayName) =>
|
|
_publishProfilePatch({'display_name': displayName.trim()});
|
|
|
|
/// Updates the current user's profile description.
|
|
Future<void> updateAbout(String about) =>
|
|
_publishProfilePatch({'about': about.trim()});
|
|
|
|
/// Updates the current user's profile photo URL.
|
|
Future<void> updateAvatarUrl(String avatarUrl) =>
|
|
_publishProfilePatch({'picture': avatarUrl.trim()});
|
|
|
|
Future<void> _publishProfilePatch(Map<String, dynamic> patch) {
|
|
final context = _currentWriteContext();
|
|
final previous = _patchQueue;
|
|
final released = Completer<void>();
|
|
_patchQueue = released.future;
|
|
return () async {
|
|
await previous;
|
|
try {
|
|
await _publishProfilePatchNow(patch, context);
|
|
} finally {
|
|
released.complete();
|
|
}
|
|
}();
|
|
}
|
|
|
|
_ProfileWriteContext _currentWriteContext() => _ProfileWriteContext(
|
|
config: ref.read(relayConfigProvider),
|
|
pubkey: ref.read(myPubkeyProvider),
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
);
|
|
|
|
Future<void> _publishProfilePatchNow(
|
|
Map<String, dynamic> patch,
|
|
_ProfileWriteContext context,
|
|
) async {
|
|
_requireCurrentWriteContext(context);
|
|
if (!_hasHydrated || !state.hasValue) {
|
|
throw StateError('Cannot update profile before metadata is loaded.');
|
|
}
|
|
final pubkey = context.pubkey;
|
|
if (pubkey == null) {
|
|
throw StateError('Cannot update profile without a signing identity.');
|
|
}
|
|
final session = context.session;
|
|
final currentEvents = await session.fetchHistory(
|
|
NostrFilters.profile(pubkey),
|
|
);
|
|
_requireCurrentWriteContext(context);
|
|
final currentHead = _latestProfileEvent(currentEvents);
|
|
if (_lastCreatedAt > 0 &&
|
|
(currentHead == null || currentHead.createdAt < _lastCreatedAt)) {
|
|
throw StateError('Cannot confirm the latest profile metadata.');
|
|
}
|
|
final currentMetadata = currentHead == null
|
|
? <String, dynamic>{}
|
|
: _decodeProfileMetadata(currentHead);
|
|
final nextMetadata = {...currentMetadata, ...patch};
|
|
if (patch['display_name'] == '') {
|
|
nextMetadata
|
|
..remove('display_name')
|
|
..remove('name');
|
|
}
|
|
final relay = SignedEventRelay(session: session, nsec: context.config.nsec);
|
|
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
final currentCreatedAt = currentHead?.createdAt ?? 0;
|
|
final previousCreatedAt = currentCreatedAt > _lastCreatedAt
|
|
? currentCreatedAt
|
|
: _lastCreatedAt;
|
|
final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1;
|
|
NostrEvent? signedEvent;
|
|
await relay.submit(
|
|
kind: EventKind.profile,
|
|
content: jsonEncode(nextMetadata),
|
|
tags: currentHead?.tags ?? const [],
|
|
createdAt: createdAt,
|
|
onSigned: (event) => signedEvent = event,
|
|
);
|
|
_requireCurrentWriteContext(context);
|
|
final submittedEvent = signedEvent;
|
|
if (submittedEvent == null) {
|
|
throw StateError('Profile update was not signed.');
|
|
}
|
|
final verifiedHead = _latestProfileEvent(
|
|
await session.fetchHistory(NostrFilters.profile(pubkey)),
|
|
);
|
|
_requireCurrentWriteContext(context);
|
|
if (verifiedHead?.id != submittedEvent.id) {
|
|
throw StateError('Profile changed before the update could be confirmed.');
|
|
}
|
|
|
|
_metadata = nextMetadata;
|
|
_lastCreatedAt = createdAt;
|
|
final profile = UserProfile(
|
|
pubkey: pubkey,
|
|
displayName:
|
|
_metadata['display_name'] as String? ?? _metadata['name'] as String?,
|
|
avatarUrl: _metadata['picture'] as String?,
|
|
about: _metadata['about'] as String?,
|
|
nip05Handle: _metadata['nip05'] as String?,
|
|
ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey),
|
|
);
|
|
state = AsyncData(profile);
|
|
// Record the confirmed event order with the profile so an older batch
|
|
// completing after this save cannot replace it.
|
|
ref.read(userCacheProvider.notifier).cacheProfileEvent(submittedEvent);
|
|
}
|
|
|
|
void _requireCurrentWriteContext(_ProfileWriteContext context) {
|
|
final currentConfig = ref.read(relayConfigProvider);
|
|
final currentSession = ref.read(relaySessionProvider.notifier);
|
|
final currentPubkey = ref.read(myPubkeyProvider);
|
|
if (currentConfig.storedOrigin != context.config.storedOrigin ||
|
|
currentConfig.nsec != context.config.nsec ||
|
|
currentPubkey != context.pubkey ||
|
|
!identical(currentSession, context.session)) {
|
|
throw ProfileCommunityChangedException();
|
|
}
|
|
}
|
|
}
|
|
|
|
class _ProfileWriteContext {
|
|
const _ProfileWriteContext({
|
|
required this.config,
|
|
required this.pubkey,
|
|
required this.session,
|
|
});
|
|
|
|
final RelayConfig config;
|
|
final String? pubkey;
|
|
final RelaySessionNotifier session;
|
|
}
|
|
|
|
NostrEvent? _latestProfileEvent(List<NostrEvent> events) {
|
|
if (events.isEmpty) return null;
|
|
return events.reduce((current, event) {
|
|
if (event.createdAt != current.createdAt) {
|
|
return event.createdAt > current.createdAt ? event : current;
|
|
}
|
|
// Match the relay replacement tie-breaker: the lowest event id wins.
|
|
return event.id.compareTo(current.id) < 0 ? event : current;
|
|
});
|
|
}
|
|
|
|
Map<String, dynamic> _decodeProfileMetadata(NostrEvent event) {
|
|
try {
|
|
final decoded = jsonDecode(event.content);
|
|
return decoded is Map<String, dynamic>
|
|
? Map<String, dynamic>.from(decoded)
|
|
: <String, dynamic>{};
|
|
} on FormatException {
|
|
return <String, dynamic>{};
|
|
}
|
|
}
|
|
|
|
final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
|
ProfileNotifier.new,
|
|
);
|
|
|
|
/// Presence status for the current user.
|
|
///
|
|
/// Sends a heartbeat every 60s while the app is active by publishing a
|
|
/// kind:20001 presence event over the relay WebSocket. Watches
|
|
/// [appLifecycleProvider] to send "away" when backgrounded.
|
|
class PresenceNotifier extends AsyncNotifier<String> {
|
|
static const _heartbeatInterval = Duration(seconds: 60);
|
|
static const _preferenceKeyPrefix = 'buzz_presence_preference_';
|
|
|
|
Timer? _heartbeatTimer;
|
|
String? _preferencePubkey;
|
|
String? _manualPresence;
|
|
|
|
@override
|
|
Future<String> build() {
|
|
ref.watch(relaySessionProvider);
|
|
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
|
|
|
|
if (_preferencePubkey != pubkey) {
|
|
_preferencePubkey = pubkey;
|
|
final stored = pubkey == null
|
|
? null
|
|
: ref
|
|
.read(savedPrefsProvider)
|
|
.getString('$_preferenceKeyPrefix$pubkey');
|
|
_manualPresence = stored == 'away' || stored == 'offline' ? stored : null;
|
|
}
|
|
|
|
final lifecycle = ref.watch(appLifecycleProvider);
|
|
|
|
ref.onDispose(() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = null;
|
|
});
|
|
|
|
final manualPresence = _manualPresence;
|
|
if (manualPresence != null) {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = null;
|
|
return _setPresence(manualPresence);
|
|
}
|
|
|
|
if (lifecycle == AppLifecycleState.resumed) {
|
|
_startHeartbeat();
|
|
return _setPresence('online');
|
|
} else if (lifecycle == AppLifecycleState.paused ||
|
|
lifecycle == AppLifecycleState.detached) {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = null;
|
|
return _setPresence('away');
|
|
}
|
|
|
|
// Default: we don't know. Reflect the most recent state we set, or
|
|
// 'offline' if never set.
|
|
return Future.value('offline');
|
|
}
|
|
|
|
void _startHeartbeat() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
|
|
_setPresence('online');
|
|
});
|
|
}
|
|
|
|
/// Updates the current user's presence preference and publishes it.
|
|
///
|
|
/// Online restores automatic lifecycle-driven presence. Away and Offline
|
|
/// remain selected until the user chooses another value.
|
|
Future<void> setPresence(String status) async {
|
|
if (status != 'online' && status != 'away' && status != 'offline') return;
|
|
|
|
_manualPresence = status == 'online' ? null : status;
|
|
final pubkey = ref.read(myPubkeyProvider)?.toLowerCase();
|
|
if (pubkey != null) {
|
|
await ref
|
|
.read(savedPrefsProvider)
|
|
.setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto');
|
|
}
|
|
|
|
if (_manualPresence == null &&
|
|
ref.read(appLifecycleProvider) == AppLifecycleState.resumed) {
|
|
_startHeartbeat();
|
|
} else {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = null;
|
|
}
|
|
|
|
state = AsyncData(status);
|
|
await _setPresence(status);
|
|
}
|
|
|
|
/// Publish a kind:20001 presence event. Returns the requested status
|
|
/// optimistically — failures are silently absorbed and the next heartbeat
|
|
/// will retry.
|
|
Future<String> _setPresence(String status) async {
|
|
final sessionState = ref.read(relaySessionProvider);
|
|
if (sessionState.status != SessionStatus.connected) return status;
|
|
final config = ref.read(relayConfigProvider);
|
|
final relay = SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: config.nsec,
|
|
);
|
|
try {
|
|
await relay.submit(
|
|
kind: EventKind.presenceUpdate,
|
|
content: status,
|
|
tags: const [],
|
|
);
|
|
} catch (_) {
|
|
// Heartbeat will retry.
|
|
}
|
|
return status;
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
// No-op: presence is driven by heartbeats and lifecycle, not pulled.
|
|
}
|
|
}
|
|
|
|
final presenceProvider = AsyncNotifierProvider<PresenceNotifier, String>(
|
|
PresenceNotifier.new,
|
|
);
|