Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion mobile/lib/features/profile/profile_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey),
);
state = AsyncData(profile);
ref.read(userCacheProvider.notifier).put(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) {
Expand Down
53 changes: 53 additions & 0 deletions mobile/lib/shared/profile/profile_event_parser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'dart:isolate';

import '../crypto/nip_oa.dart';
import '../relay/nostr_models.dart';
import 'user_profile.dart';

/// A verified profile together with its NIP-01 replacement order.
typedef ParsedProfileEvent = ({
UserProfile profile,
int createdAt,
String eventId,
});

/// Parses and verifies profile history without blocking the UI isolate.
///
/// Capture only the event batch here, never a provider or its surrounding
/// context. NIP-OA verification performs synchronous elliptic-curve work.
Future<List<ParsedProfileEvent>> parseProfileEventBatch(
List<NostrEvent> events,
) => Isolate.run(() => _parseBatch(events));

List<ParsedProfileEvent> _parseBatch(List<NostrEvent> events) {
final latest = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pubkey = event.pubkey.toLowerCase();
final current = latest[pubkey];
if (current == null ||
event.createdAt > current.createdAt ||
(event.createdAt == current.createdAt &&
event.id.compareTo(current.id) < 0)) {
latest[pubkey] = event;
}
}
return [for (final event in latest.values) parseProfileEvent(event)];
}

/// Parses a single live profile, including its verified owner attribution.
ParsedProfileEvent parseProfileEvent(NostrEvent event) {
final data = ProfileData.fromEvent(event);
return (
profile: UserProfile(
pubkey: data.pubkey.toLowerCase(),
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
),
createdAt: event.createdAt,
eventId: event.id,
);
}
148 changes: 93 additions & 55 deletions mobile/lib/shared/profile/user_cache_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../community/community_provider.dart';
import '../crypto/nip_oa.dart';
import '../push/push_presentation_cache.dart';
import '../relay/relay.dart';
import 'user_profile.dart';
import 'profile_event_parser.dart';

/// In-memory cache of user profiles, fetched in batches from the relay.
///
Expand All @@ -15,14 +15,20 @@ import 'user_profile.dart';
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final Set<String> _pending = {};
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
int _generation = 0;
bool _flushInFlight = false;
Future<void> _verificationQueue = Future.value();
Timer? _batchTimer;
Completer<bool>? _batchCompleter;

@override
Map<String, UserProfile> build() {
ref.watch(relayConfigProvider);
_generation++;
_profileEventOrders.clear();
ref.onDispose(() {
_generation++;
_pending.clear();
_batchTimer?.cancel();
_batchTimer = null;
_batchCompleter?.complete(false);
Expand Down Expand Up @@ -57,7 +63,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
if (uncached.isEmpty && !alreadyPending) return Future.value(true);
_pending.addAll(uncached);
final completer = _batchCompleter ??= Completer<bool>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
_scheduleBatch();
return completer.future;
}

Expand All @@ -72,23 +78,14 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
.toSet()
.toList();
if (normalized.isEmpty) return true;
final generation = _generation;
try {
final session = ref.read(relaySessionProvider.notifier);
final events = await session.fetchHistory(
NostrFilters.profilesBatch(normalized),
);
final updated = Map<String, UserProfile>.from(state);
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
_profileEventOrders,
);
for (final event in events) {
_cacheProfileEvent(event, updated, updatedOrders);
}
_profileEventOrders
..clear()
..addAll(updatedOrders);
state = updated;
return true;
if (!_isCurrent(generation)) return false;
return await _verifyAndMerge(events, generation);
} catch (_) {
return false;
}
Expand All @@ -108,7 +105,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
if (state.containsKey(pubkey) || _pending.contains(pubkey)) return;
_pending.add(pubkey);
_batchCompleter ??= Completer<bool>();
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
_scheduleBatch();
}

Future<void> _flushPending() async {
Expand All @@ -120,6 +117,8 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final completer = _batchCompleter;
_batchCompleter = null;

_flushInFlight = true;
final generation = _generation;
var succeeded = false;
try {
final communityID = ref.read(activeCommunityProvider).value?.id;
Expand All @@ -128,18 +127,8 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
NostrFilters.profilesBatch(pubkeys),
);

final updated = Map<String, UserProfile>.from(state);
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
_profileEventOrders,
);
for (final event in events) {
_cacheProfileEvent(event, updated, updatedOrders);
}

_profileEventOrders
..clear()
..addAll(updatedOrders);
state = updated;
if (!_isCurrent(generation)) return;
if (!await _verifyAndMerge(events, generation)) return;
if (communityID != null) {
unawaited(cacheBuzzPushProfileEvents(communityID, events));
}
Expand All @@ -148,43 +137,92 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
// Silently fail — non-gating callers will just show pubkeys.
} finally {
completer?.complete(succeeded);
_flushInFlight = false;
if (ref.mounted && _pending.isNotEmpty) _scheduleBatch();
}
}

bool _cacheProfileEvent(
NostrEvent event,
Map<String, UserProfile> profiles, [
Map<String, ({int createdAt, String eventId})>? orders,
]) {
void _scheduleBatch() {
if (_flushInFlight) return;
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
}

Future<bool> _verifyAndMerge(List<NostrEvent> events, int generation) {
// Both refresh and preload share one worker slot. Keep this queue across
// community changes so an old worker cannot overlap a new community's job.
final result = _verificationQueue.then((_) async {
if (!_isCurrent(generation)) return false;
// Match live-cache admission before parsing. In particular, a malformed
// stale profile must not fail a refresh that previously ignored it.
final pending = [
for (final event in events)
if (event.kind == 0 &&
_isNewer(event.pubkey.toLowerCase(), event.createdAt, event.id))
event,
];
if (pending.isEmpty) return true;
final profiles = await ref.read(profileEventBatchParserProvider)(pending);
if (!_isCurrent(generation)) return false;
_mergeParsedProfiles(profiles);
return true;
});
// Callers receive the original error; only the sequencing tail recovers so
// one rejected batch does not poison all future refreshes.
_verificationQueue = result.then<void>(
(_) {},
onError: (Object error, StackTrace stack) {},
);
return result;
}

bool _isCurrent(int generation) => ref.mounted && generation == _generation;

void _mergeParsedProfiles(List<ParsedProfileEvent> profiles) {
// Read the current state after the isolate completes: live events and other
// batches may have installed newer profiles while this batch was parsing.
final updated = Map<String, UserProfile>.from(state);
var changed = false;
for (final parsed in profiles) {
if (!_isNewer(parsed.profile.pubkey, parsed.createdAt, parsed.eventId)) {
continue;
}
updated[parsed.profile.pubkey] = parsed.profile;
Comment thread
brow marked this conversation as resolved.
_profileEventOrders[parsed.profile.pubkey] = (
createdAt: parsed.createdAt,
eventId: parsed.eventId,
);
changed = true;
}
if (changed) state = updated;
}

bool _isNewer(String pubkey, int createdAt, String eventId) {
final current = _profileEventOrders[pubkey];
return current == null ||
createdAt > current.createdAt ||
(createdAt == current.createdAt &&
eventId.compareTo(current.eventId) < 0);
}

bool _cacheProfileEvent(NostrEvent event, Map<String, UserProfile> profiles) {
if (event.kind != 0) return false;
final eventOrders = orders ?? _profileEventOrders;
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);
if (!isNewer) return false;

profiles[pubkey] = _profileFromEvent(event);
eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id);
if (!_isNewer(pubkey, event.createdAt, event.id)) return false;
profiles[pubkey] = parseProfileEvent(event).profile;
_profileEventOrders[pubkey] = (
createdAt: event.createdAt,
eventId: event.id,
);
return true;
}
}

UserProfile _profileFromEvent(NostrEvent event) {
final data = ProfileData.fromEvent(event);
final pubkey = data.pubkey.toLowerCase();
return UserProfile(
pubkey: pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey),
/// Profile history parser, isolated from provider state so batch completion can
/// be delayed independently of relay delivery.
final profileEventBatchParserProvider =
Provider<Future<List<ParsedProfileEvent>> Function(List<NostrEvent>)>(
(ref) => parseProfileEventBatch,
);
}
}

final userCacheProvider =
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
Expand Down
71 changes: 71 additions & 0 deletions mobile/test/features/profile/profile_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/shared/profile/profile_event_parser.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
Expand Down Expand Up @@ -78,6 +79,76 @@ void main() {
);
});

test('confirmed profile save survives an older in-flight batch', () async {
final keys = nostr.Keys.generate();
final owner = nostr.Keys.generate();
NostrEvent event(String id, int createdAt, {bool owned = false}) =>
NostrEvent(
id: id,
pubkey: keys.public,
createdAt: createdAt,
kind: EventKind.profile,
tags: owned ? [_authTag(owner, keys.public)] : const [],
content: jsonEncode({
'display_name': owned ? 'Current' : 'Old relay profile',
'picture': owned ? 'https://example.com/new.png' : 'old.png',
'about': owned ? 'Current about' : 'Old about',
'nip05': owned ? 'agent@example.com' : 'old@example.com',
}),
sig: 'sig',
);
var history = [event('old', 1)];
final relaySession = _ControlledProfileRelaySession(
fetch: () async => history,
);
final parserStarted = Completer<void>();
final releaseParser = Completer<void>();
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(
() => _FixedRelayConfigNotifier(keys.nsec),
),
relaySessionProvider.overrideWith(() => relaySession),
profileEventBatchParserProvider.overrideWithValue((events) async {
final parsed = await parseProfileEventBatch(events);
parserStarted.complete();
await releaseParser.future;
return parsed;
}),
],
);
addTearDown(container.dispose);

await container.read(profileProvider.future);
final cache = container.read(userCacheProvider.notifier);
final refresh = cache.refresh([keys.public]);
await parserStarted.future;
history = [event('current', 2, owned: true)];
await container
.read(profileProvider.notifier)
.updateDisplayName('Saved locally');
final saved = container.read(userCacheProvider)[keys.public]!;
expect(saved.displayName, 'Saved locally');
expect(saved.avatarUrl, 'https://example.com/new.png');
expect(saved.about, 'Current about');
expect(saved.nip05Handle, 'agent@example.com');
expect(saved.ownerPubkey, owner.public.toLowerCase());
expect(relaySession.published, hasLength(1));

releaseParser.complete();
expect(await refresh, isTrue);
final afterBatch = container.read(userCacheProvider)[keys.public]!;
expect(afterBatch.displayName, 'Saved locally');
expect(afterBatch.avatarUrl, saved.avatarUrl);
expect(afterBatch.about, saved.about);
expect(afterBatch.nip05Handle, saved.nip05Handle);
expect(afterBatch.ownerPubkey, owner.public.toLowerCase());
expect(
container.read(profileProvider).requireValue?.ownerPubkey,
owner.public.toLowerCase(),
);
});

test('clearing a display name restores the pubkey label fallback', () async {
final keys = nostr.Keys.generate();
final relaySession = _ProfileRelaySession(
Expand Down
Loading