feat(block2): profiles — your identity card + name/about (NIP-01 kind:0)

Drawer 'Profile' is now live.
- commons_core: ProfileTransport + UserProfile + NostrProfileTransport (kind:0
  publish/fetch), exposed as SocialSession.profile. Round-trip tested against the
  in-process relay (fast dart test).
- app: ProfileStore (local name/about, keystore) + ProfileScreen — shows your
  shareable npub with copy, edits display name + 'about', saves locally and (when
  online) publishes kind:0 so peers can recognise you instead of a raw key.
- Drawer 'Profile' -> /profile (gated like Market/Chat).
- i18n en/es/pt. ProfileStore + profile transport covered by plain/dart tests.

Analyzer clean (both packages).
This commit is contained in:
vjrj 2026-07-10 12:37:07 +02:00
parent abc2d37402
commit 30246f4292
19 changed files with 562 additions and 4 deletions

View file

@ -18,10 +18,12 @@ export 'src/social/nostr/nostr_channel.dart';
export 'src/social/nostr/nostr_connection.dart';
export 'src/social/nostr/nostr_message_transport.dart';
export 'src/social/nostr/nostr_offer_transport.dart';
export 'src/social/nostr/nostr_profile_transport.dart';
export 'src/social/nostr/nostr_trust_transport.dart';
export 'src/social/nostr/relay_pool.dart';
export 'src/social/offer.dart';
export 'src/social/offer_transport.dart';
export 'src/social/profile_transport.dart';
export 'src/social/trust_transport.dart';
export 'src/social/web_of_trust.dart';
export 'src/value/quantity.dart';

View file

@ -0,0 +1,61 @@
import 'dart:convert';
import 'package:nostr/nostr.dart';
import '../profile_transport.dart';
import 'nostr_channel.dart';
/// Nostr NIP-01 kind:0 backend for [ProfileTransport], on the shared channel.
class NostrProfileTransport implements ProfileTransport {
NostrProfileTransport(this._conn);
final NostrChannel _conn;
/// NIP-01 set_metadata (replaceable).
static const kindMetadata = 0;
@override
Future<void> publish({
String name = '',
String about = '',
String picture = '',
}) async {
final content = jsonEncode({
if (name.isNotEmpty) 'name': name,
if (about.isNotEmpty) 'about': about,
if (picture.isNotEmpty) 'picture': picture,
});
final event = Event.from(
kind: kindMetadata,
content: content,
tags: const [],
secretKey: _conn.privateKeyHex,
);
await _conn.publish(event);
}
@override
Future<UserProfile?> fetch(String pubkeyHex) async {
final events = await _conn.reqOnce(
Filter(kinds: const [kindMetadata], authors: [pubkeyHex], limit: 1),
);
if (events.isEmpty) return null;
// Newest wins if several slipped through.
events.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final event = events.first;
try {
final map = jsonDecode(event.content) as Map<String, dynamic>;
return UserProfile(
pubkeyHex: pubkeyHex,
name: (map['name'] as String?) ?? '',
about: (map['about'] as String?) ?? '',
picture: (map['picture'] as String?) ?? '',
);
} catch (_) {
return UserProfile(pubkeyHex: pubkeyHex);
}
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,30 @@
/// A user's public profile (NIP-01 kind:0 metadata) — the human name others see
/// instead of a raw key. Only what the person chooses to publish.
class UserProfile {
const UserProfile({
required this.pubkeyHex,
this.name = '',
this.about = '',
this.picture = '',
});
final String pubkeyHex;
final String name;
final String about;
final String picture;
bool get isEmpty => name.isEmpty && about.isEmpty && picture.isEmpty;
}
/// Publish/fetch public profile metadata. Kind:0 is replaceable publishing
/// again overwrites. Part of the social layer (identity is generic core), a
/// sibling of the offer/message/trust transports over the same connection.
abstract interface class ProfileTransport {
/// Publishes (replaces) this identity's profile.
Future<void> publish({String name, String about, String picture});
/// Fetches [pubkeyHex]'s latest profile, or null if they never published one.
Future<UserProfile?> fetch(String pubkeyHex);
Future<void> close();
}

View file

@ -0,0 +1,46 @@
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
import '../support/mini_relay.dart';
void main() {
Future<NostrIdentity> idFor(int fill) =>
NostrKeyDerivation.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
test('publish then fetch round-trips a profile (kind:0)', () async {
final alice = await idFor(1);
final aliceConn = await NostrConnection.connect(relay.url, identity: alice);
await NostrProfileTransport(aliceConn)
.publish(name: 'Alicia', about: 'Guardo tomate rosa');
// Anyone else can fetch it.
final bob = await idFor(2);
final bobConn = await NostrConnection.connect(relay.url, identity: bob);
final fetched =
await NostrProfileTransport(bobConn).fetch(alice.publicKeyHex);
expect(fetched, isNotNull);
expect(fetched!.name, 'Alicia');
expect(fetched.about, 'Guardo tomate rosa');
await aliceConn.close();
await bobConn.close();
});
test('fetch returns null when the peer never published a profile', () async {
final me = await idFor(3);
final conn = await NostrConnection.connect(relay.url, identity: me);
final stranger = await idFor(9);
expect(
await NostrProfileTransport(conn).fetch(stranger.publicKeyHex),
isNull,
);
await conn.close();
});
}