mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(mobile): order confirmed profile writes with shared cache authority
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -69,9 +68,7 @@ final profileAvatarHandoffProvider =
|
||||
/// 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
|
||||
@@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
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),
|
||||
);
|
||||
_requireCurrentWriteContext(context);
|
||||
_metadata = metadata;
|
||||
_lastCreatedAt = latest.createdAt;
|
||||
final cache = ref.read(userCacheProvider.notifier);
|
||||
for (final event in events) {
|
||||
cache.cacheProfileEvent(event);
|
||||
}
|
||||
_hasHydrated = true;
|
||||
return profile;
|
||||
return ref.read(userCacheProvider)[myPk.toLowerCase()];
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
@@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
NostrFilters.profile(pubkey),
|
||||
);
|
||||
_requireCurrentWriteContext(context);
|
||||
final currentHead = _latestProfileEvent(currentEvents);
|
||||
if (_lastCreatedAt > 0 &&
|
||||
(currentHead == null || currentHead.createdAt < _lastCreatedAt)) {
|
||||
final cache = ref.read(userCacheProvider.notifier);
|
||||
for (final event in currentEvents) {
|
||||
cache.cacheProfileEvent(event);
|
||||
}
|
||||
final currentHead = cache.profileEvent(pubkey);
|
||||
if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) {
|
||||
throw StateError('Cannot confirm the latest profile metadata.');
|
||||
}
|
||||
final currentMetadata = currentHead == null
|
||||
@@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
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;
|
||||
final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1;
|
||||
NostrEvent? signedEvent;
|
||||
await relay.submit(
|
||||
kind: EventKind.profile,
|
||||
@@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
|
||||
if (submittedEvent == null) {
|
||||
throw StateError('Profile update was not signed.');
|
||||
}
|
||||
final verifiedHead = _latestProfileEvent(
|
||||
await session.fetchHistory(NostrFilters.profile(pubkey)),
|
||||
final verification = await session.fetchHistory(
|
||||
NostrFilters.profile(pubkey),
|
||||
);
|
||||
_requireCurrentWriteContext(context);
|
||||
for (final event in verification) {
|
||||
cache.cacheProfileEvent(event);
|
||||
}
|
||||
final verifiedHead = _latestProfileEvent(verification);
|
||||
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),
|
||||
);
|
||||
state = AsyncData(profile);
|
||||
ref.read(userCacheProvider.notifier).put(profile);
|
||||
cache.cacheProfileEvent(submittedEvent);
|
||||
state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]);
|
||||
}
|
||||
|
||||
void _requireCurrentWriteContext(_ProfileWriteContext context) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import 'user_profile.dart';
|
||||
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
final Set<String> _pending = {};
|
||||
int _generation = 0;
|
||||
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
|
||||
final Map<String, NostrEvent> _profileEvents = {};
|
||||
Timer? _batchTimer;
|
||||
Completer<bool>? _batchCompleter;
|
||||
|
||||
@@ -25,7 +25,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
ref.watch(myPubkeyProvider);
|
||||
_generation++;
|
||||
_pending.clear();
|
||||
_profileEventOrders.clear();
|
||||
_profileEvents.clear();
|
||||
ref.onDispose(() {
|
||||
_generation++;
|
||||
_batchTimer?.cancel();
|
||||
@@ -45,11 +45,19 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stores a profile that was fetched or updated outside the batch loader.
|
||||
/// Seeds an absent profile for local display before signed evidence arrives.
|
||||
/// Never replaces existing state; fetched/confirmed profiles must use
|
||||
/// [cacheProfileEvent] so ownership and event metadata advance together.
|
||||
void put(UserProfile profile) {
|
||||
state = {...state, profile.pubkey.toLowerCase(): profile};
|
||||
final pubkey = profile.pubkey.toLowerCase();
|
||||
if (state.containsKey(pubkey)) return;
|
||||
state = {...state, pubkey: profile};
|
||||
}
|
||||
|
||||
/// The governing kind:0 event, including metadata needed for profile edits.
|
||||
NostrEvent? profileEvent(String pubkey) =>
|
||||
_profileEvents[pubkey.toLowerCase()];
|
||||
|
||||
/// Preload profiles for a list of pubkeys (e.g. channel members).
|
||||
/// Returns whether the batch completed successfully.
|
||||
Future<bool> preload(List<String> pubkeys) {
|
||||
@@ -85,13 +93,11 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
);
|
||||
if (generation != _generation) return false;
|
||||
final updated = Map<String, UserProfile>.from(state);
|
||||
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
|
||||
_profileEventOrders,
|
||||
);
|
||||
final updatedOrders = Map<String, NostrEvent>.from(_profileEvents);
|
||||
for (final event in events) {
|
||||
_cacheProfileEvent(event, updated, updatedOrders);
|
||||
}
|
||||
_profileEventOrders
|
||||
_profileEvents
|
||||
..clear()
|
||||
..addAll(updatedOrders);
|
||||
state = updated;
|
||||
@@ -138,14 +144,12 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
|
||||
if (generation != _generation) return;
|
||||
final updated = Map<String, UserProfile>.from(state);
|
||||
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
|
||||
_profileEventOrders,
|
||||
);
|
||||
final updatedOrders = Map<String, NostrEvent>.from(_profileEvents);
|
||||
for (final event in events) {
|
||||
_cacheProfileEvent(event, updated, updatedOrders);
|
||||
}
|
||||
|
||||
_profileEventOrders
|
||||
_profileEvents
|
||||
..clear()
|
||||
..addAll(updatedOrders);
|
||||
state = updated;
|
||||
@@ -163,21 +167,21 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
bool _cacheProfileEvent(
|
||||
NostrEvent event,
|
||||
Map<String, UserProfile> profiles, [
|
||||
Map<String, ({int createdAt, String eventId})>? orders,
|
||||
Map<String, NostrEvent>? orders,
|
||||
]) {
|
||||
if (event.kind != 0) return false;
|
||||
final eventOrders = orders ?? _profileEventOrders;
|
||||
final eventOrders = orders ?? _profileEvents;
|
||||
final pubkey = event.pubkey.toLowerCase();
|
||||
final current = eventOrders[pubkey];
|
||||
final isNewer =
|
||||
current == null ||
|
||||
event.createdAt > current.createdAt ||
|
||||
(event.createdAt == current.createdAt &&
|
||||
event.id.compareTo(current.eventId) < 0);
|
||||
event.id.compareTo(current.id) < 0);
|
||||
if (!isNewer) return false;
|
||||
|
||||
profiles[pubkey] = _profileFromEvent(event);
|
||||
eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id);
|
||||
eventOrders[pubkey] = event;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,89 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'late confirmed publication retains newer cache authority and metadata',
|
||||
() async {
|
||||
final keys = nostr.Keys.generate();
|
||||
final owner = nostr.Keys.generate();
|
||||
final initial = NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
secretKey: keys.secret,
|
||||
createdAt: 1,
|
||||
kind: 0,
|
||||
tags: [_authTag(owner, keys.public)],
|
||||
content: jsonEncode({'display_name': 'Owned'}),
|
||||
).toMap(),
|
||||
);
|
||||
final session = _ProfileRelaySession(initial);
|
||||
final container = _profileContainer(keys.nsec, session);
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
await container.read(profileProvider.future);
|
||||
expect(cache.profileEvent(keys.public)?.id, initial.id);
|
||||
NostrEvent? revoked;
|
||||
session.beforeHistoryReturn = () {
|
||||
if (session.published.length != 1) return;
|
||||
revoked = NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
secretKey: keys.secret,
|
||||
createdAt: session.published.single.createdAt + 1,
|
||||
kind: 0,
|
||||
tags: const [],
|
||||
content: jsonEncode({'display_name': 'Revoked', 'custom': 'new'}),
|
||||
).toMap(),
|
||||
);
|
||||
cache.cacheProfileEvent(revoked!);
|
||||
};
|
||||
await container.read(profileProvider.notifier).updateAbout('Late');
|
||||
expect(
|
||||
container.read(profileProvider).requireValue?.displayName,
|
||||
'Revoked',
|
||||
);
|
||||
final submitted = session.published.single;
|
||||
cache.put(
|
||||
UserProfile(
|
||||
pubkey: keys.public,
|
||||
displayName: 'Unordered',
|
||||
ownerPubkey: owner.public,
|
||||
),
|
||||
);
|
||||
cache.cacheProfileEvent(submitted);
|
||||
cache.cacheProfileEvent(revoked!);
|
||||
expect(cache.profileEvent(keys.public)?.id, revoked!.id);
|
||||
expect(
|
||||
container.read(userCacheProvider)[keys.public]?.ownerPubkey,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
container.read(profileProvider).requireValue?.displayName,
|
||||
'Revoked',
|
||||
);
|
||||
// A stale hydration response must use the same governing event, not
|
||||
// recreate detached state or lower the metadata used by the next edit.
|
||||
session.beforeHistoryReturn = null;
|
||||
await container.read(profileProvider.notifier).refresh();
|
||||
expect(
|
||||
container.read(profileProvider).requireValue?.displayName,
|
||||
'Revoked',
|
||||
);
|
||||
await expectLater(
|
||||
container.read(profileProvider.notifier).updateAbout('Unsafe'),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(session.published, hasLength(1));
|
||||
session.profile = revoked!;
|
||||
await container.read(profileProvider.notifier).updateAbout('Safe');
|
||||
final next = session.published.last;
|
||||
expect(next.createdAt, greaterThan(revoked!.createdAt));
|
||||
expect(next.tags, isEmpty);
|
||||
expect(jsonDecode(next.content)['custom'], 'new');
|
||||
expect(cache.profileEvent(keys.public)?.id, next.id);
|
||||
cache.cacheProfileEvent(revoked!);
|
||||
expect(cache.profileEvent(keys.public)?.id, next.id);
|
||||
},
|
||||
);
|
||||
|
||||
test('clearing a display name restores the pubkey label fallback', () async {
|
||||
final keys = nostr.Keys.generate();
|
||||
final relaySession = _ProfileRelaySession(
|
||||
@@ -607,7 +690,8 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier {
|
||||
class _ProfileRelaySession extends RelaySessionNotifier {
|
||||
_ProfileRelaySession(this.profile);
|
||||
|
||||
final NostrEvent profile;
|
||||
NostrEvent profile;
|
||||
void Function()? beforeHistoryReturn;
|
||||
final List<NostrEvent> published = [];
|
||||
|
||||
@override
|
||||
@@ -617,7 +701,11 @@ class _ProfileRelaySession extends RelaySessionNotifier {
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [profile, ...published];
|
||||
}) async {
|
||||
final result = [profile, ...published];
|
||||
beforeHistoryReturn?.call();
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NostrEvent> publish(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -176,6 +177,34 @@ void main() {
|
||||
expect(cache.state['agent']?.displayName, 'Valid');
|
||||
});
|
||||
|
||||
test(
|
||||
'unordered seed is first hydration only and cannot replace an event',
|
||||
() {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
cache.put(const UserProfile(pubkey: 'AGENT', displayName: 'Local'));
|
||||
expect(cache.state['agent']?.displayName, 'Local');
|
||||
expect(cache.profileEvent('agent'), isNull);
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(id: 'b', createdAt: 2, name: 'New'),
|
||||
);
|
||||
cache.put(
|
||||
const UserProfile(
|
||||
pubkey: 'agent',
|
||||
displayName: 'Late',
|
||||
ownerPubkey: 'owner',
|
||||
),
|
||||
);
|
||||
cache.cacheProfileEvent(
|
||||
_profileEvent(id: 'a', createdAt: 1, name: 'Old'),
|
||||
);
|
||||
expect(cache.state['agent']?.displayName, 'New');
|
||||
expect(cache.state['agent']?.ownerPubkey, isNull);
|
||||
expect(cache.profileEvent('AGENT')?.id, 'b');
|
||||
},
|
||||
);
|
||||
|
||||
test('same-second profile tie keeps the lowest event id', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
Reference in New Issue
Block a user