From 30246f4292d53331d2dd5eb8e157ef062f9f9e63 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 12:37:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(block2):=20profiles=20=E2=80=94=20your=20i?= =?UTF-8?q?dentity=20card=20+=20name/about=20(NIP-01=20kind:0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/app_seeds/lib/app.dart | 17 ++ apps/app_seeds/lib/di/injector.dart | 2 + apps/app_seeds/lib/i18n/en.i18n.json | 13 ++ apps/app_seeds/lib/i18n/es.i18n.json | 13 ++ apps/app_seeds/lib/i18n/pt.i18n.json | 13 ++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 54 +++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 32 +++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 32 +++ apps/app_seeds/lib/main.dart | 2 + .../app_seeds/lib/services/profile_store.dart | 20 ++ .../lib/services/social_service.dart | 4 +- apps/app_seeds/lib/ui/app_drawer.dart | 11 +- apps/app_seeds/lib/ui/profile_screen.dart | 190 ++++++++++++++++++ .../test/services/profile_store_test.dart | 20 ++ packages/commons_core/lib/commons_core.dart | 2 + .../social/nostr/nostr_profile_transport.dart | 61 ++++++ .../lib/src/social/profile_transport.dart | 30 +++ .../test/social/nostr_profile_test.dart | 46 +++++ 19 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 apps/app_seeds/lib/services/profile_store.dart create mode 100644 apps/app_seeds/lib/ui/profile_screen.dart create mode 100644 apps/app_seeds/test/services/profile_store_test.dart create mode 100644 packages/commons_core/lib/src/social/nostr/nostr_profile_transport.dart create mode 100644 packages/commons_core/lib/src/social/profile_transport.dart create mode 100644 packages/commons_core/test/social/nostr_profile_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 6de0836..1b7788c 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -12,6 +12,7 @@ import 'services/coarse_location.dart'; import 'services/message_store.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; +import 'services/profile_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'state/inventory_cubit.dart'; @@ -24,6 +25,7 @@ import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; import 'ui/market_screen.dart'; +import 'ui/profile_screen.dart'; import 'ui/settings_screen.dart'; import 'ui/theme.dart'; import 'ui/variety_detail_screen.dart'; @@ -41,6 +43,7 @@ class TaneApp extends StatelessWidget { this.location, this.outbox, this.messageStore, + this.profileStore, this.showIntro = false, this.autoBackup, super.key, @@ -53,6 +56,7 @@ class TaneApp extends StatelessWidget { location, outbox, messageStore, + profileStore, ); final VarietyRepository repository; @@ -72,6 +76,9 @@ class TaneApp extends StatelessWidget { /// Optional persistence for chat history. final MessageStore? messageStore; + + /// Optional local store for your own profile (name/about). + final ProfileStore? profileStore; final bool showIntro; /// Drives silent periodic backups off the app lifecycle. Null in widget tests @@ -88,6 +95,7 @@ class TaneApp extends StatelessWidget { CoarseLocationProvider? location, OfferOutbox? outbox, MessageStore? messageStore, + ProfileStore? profileStore, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', @@ -112,6 +120,15 @@ class TaneApp extends StatelessWidget { path: '/messages', builder: (context, state) => ChatListScreen(store: messageStore), ), + if (social != null && socialSettings != null && profileStore != null) + GoRoute( + path: '/profile', + builder: (context, state) => ProfileScreen( + social: social, + settings: socialSettings, + profileStore: profileStore, + ), + ), if (social != null && socialSettings != null) GoRoute( path: '/chat/:pubkey', diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 28e828d..47706a9 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -29,6 +29,7 @@ import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; import '../services/message_store.dart'; import '../services/offer_outbox.dart'; +import '../services/profile_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -92,6 +93,7 @@ Future configureDependencies() async { ..registerSingleton(SocialSettings(secretStore)) ..registerSingleton(OfferOutbox(secretStore)) ..registerSingleton(MessageStore(secretStore)) + ..registerSingleton(ProfileStore(secretStore)) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 5d5e23b..9d61506 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -361,6 +361,19 @@ "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", "queued": "Saved — we'll share these when you're connected" }, + "profile": { + "title": "Your profile", + "name": "Display name", + "nameHint": "How others see you", + "about": "About", + "aboutHint": "A short line — what you grow, where", + "yourId": "Your identity", + "idHelp": "Share this so people can recognise you", + "copy": "Copy", + "copied": "Copied", + "save": "Save", + "saved": "Profile saved" + }, "chatList": { "title": "Messages", "empty": "No conversations yet. Message someone from the market." diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 78f1142..8957e1b 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -361,6 +361,19 @@ "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", "queued": "Guardado — las compartiremos cuando tengas conexión" }, + "profile": { + "title": "Tu perfil", + "name": "Nombre", + "nameHint": "Cómo te ven los demás", + "about": "Sobre ti", + "aboutHint": "Una línea — qué cultivas, dónde", + "yourId": "Tu identidad", + "idHelp": "Compártela para que te reconozcan", + "copy": "Copiar", + "copied": "Copiado", + "save": "Guardar", + "saved": "Perfil guardado" + }, "chatList": { "title": "Mensajes", "empty": "Aún no hay conversaciones. Escribe a alguien desde el mercado." diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index bfa2144..20408da 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -357,6 +357,19 @@ "locationFailed": "Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida", "queued": "Guardado — vamos partilhá-las quando tiveres ligação" }, + "profile": { + "title": "O teu perfil", + "name": "Nome", + "nameHint": "Como os outros te veem", + "about": "Sobre ti", + "aboutHint": "Uma linha — o que cultivas, onde", + "yourId": "A tua identidade", + "idHelp": "Partilha-a para que te reconheçam", + "copy": "Copiar", + "copied": "Copiado", + "save": "Guardar", + "saved": "Perfil guardado" + }, "chatList": { "title": "Mensagens", "empty": "Ainda não há conversas. Escreve a alguém a partir do mercado." diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 685034b..b2cca1f 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 998 (332 per locale) +/// Strings: 1031 (343 per locale) /// -/// Built on 2026-07-10 at 10:26 UTC +/// Built on 2026-07-10 at 10:36 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 881e3ae..747b95f 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -70,6 +70,7 @@ class Translations with BaseTranslations { late final Translations$desiccant$en desiccant = Translations$desiccant$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root); late final Translations$market$en market = Translations$market$en.internal(_root); + late final Translations$profile$en profile = Translations$profile$en.internal(_root); late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root); late final Translations$chat$en chat = Translations$chat$en.internal(_root); late final Translations$trust$en trust = Translations$trust$en.internal(_root); @@ -1143,6 +1144,48 @@ class Translations$market$en { String get queued => 'Saved — we\'ll share these when you\'re connected'; } +// Path: profile +class Translations$profile$en { + Translations$profile$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your profile' + String get title => 'Your profile'; + + /// en: 'Display name' + String get name => 'Display name'; + + /// en: 'How others see you' + String get nameHint => 'How others see you'; + + /// en: 'About' + String get about => 'About'; + + /// en: 'A short line — what you grow, where' + String get aboutHint => 'A short line — what you grow, where'; + + /// en: 'Your identity' + String get yourId => 'Your identity'; + + /// en: 'Share this so people can recognise you' + String get idHelp => 'Share this so people can recognise you'; + + /// en: 'Copy' + String get copy => 'Copy'; + + /// en: 'Copied' + String get copied => 'Copied'; + + /// en: 'Save' + String get save => 'Save'; + + /// en: 'Profile saved' + String get saved => 'Profile saved'; +} + // Path: chatList class Translations$chatList$en { Translations$chatList$en.internal(this._root); @@ -1985,6 +2028,17 @@ extension on Translations { 'market.useLocation' => 'Use my approximate location', 'market.locationFailed' => 'Couldn\'t get your location — check that location is on and the permission is granted', 'market.queued' => 'Saved — we\'ll share these when you\'re connected', + 'profile.title' => 'Your profile', + 'profile.name' => 'Display name', + 'profile.nameHint' => 'How others see you', + 'profile.about' => 'About', + 'profile.aboutHint' => 'A short line — what you grow, where', + 'profile.yourId' => 'Your identity', + 'profile.idHelp' => 'Share this so people can recognise you', + 'profile.copy' => 'Copy', + 'profile.copied' => 'Copied', + 'profile.save' => 'Save', + 'profile.saved' => 'Profile saved', 'chatList.title' => 'Messages', 'chatList.empty' => 'No conversations yet. Message someone from the market.', 'chat.title' => 'Chat', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index c6dd643..a18ecd9 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -69,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Guardado — las compartiremos cuando tengas conexión'; } +// Path: profile +class _Translations$profile$es extends Translations$profile$en { + _Translations$profile$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Tu perfil'; + @override String get name => 'Nombre'; + @override String get nameHint => 'Cómo te ven los demás'; + @override String get about => 'Sobre ti'; + @override String get aboutHint => 'Una línea — qué cultivas, dónde'; + @override String get yourId => 'Tu identidad'; + @override String get idHelp => 'Compártela para que te reconozcan'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiado'; + @override String get save => 'Guardar'; + @override String get saved => 'Perfil guardado'; +} + // Path: chatList class _Translations$chatList$es extends Translations$chatList$en { _Translations$chatList$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1337,6 +1358,17 @@ extension on TranslationsEs { 'market.useLocation' => 'Usar mi ubicación aproximada', 'market.locationFailed' => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido', 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', + 'profile.title' => 'Tu perfil', + 'profile.name' => 'Nombre', + 'profile.nameHint' => 'Cómo te ven los demás', + 'profile.about' => 'Sobre ti', + 'profile.aboutHint' => 'Una línea — qué cultivas, dónde', + 'profile.yourId' => 'Tu identidad', + 'profile.idHelp' => 'Compártela para que te reconozcan', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiado', + 'profile.save' => 'Guardar', + 'profile.saved' => 'Perfil guardado', 'chatList.title' => 'Mensajes', 'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.', 'chat.title' => 'Chat', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 2b41df7..882f447 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -69,6 +69,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Guardado — vamos partilhá-las quando tiveres ligação'; } +// Path: profile +class _Translations$profile$pt extends Translations$profile$en { + _Translations$profile$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'O teu perfil'; + @override String get name => 'Nome'; + @override String get nameHint => 'Como os outros te veem'; + @override String get about => 'Sobre ti'; + @override String get aboutHint => 'Uma linha — o que cultivas, onde'; + @override String get yourId => 'A tua identidade'; + @override String get idHelp => 'Partilha-a para que te reconheçam'; + @override String get copy => 'Copiar'; + @override String get copied => 'Copiado'; + @override String get save => 'Guardar'; + @override String get saved => 'Perfil guardado'; +} + // Path: chatList class _Translations$chatList$pt extends Translations$chatList$en { _Translations$chatList$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1329,6 +1350,17 @@ extension on TranslationsPt { 'market.useLocation' => 'Usar a minha localização aproximada', 'market.locationFailed' => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida', 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', + 'profile.title' => 'O teu perfil', + 'profile.name' => 'Nome', + 'profile.nameHint' => 'Como os outros te veem', + 'profile.about' => 'Sobre ti', + 'profile.aboutHint' => 'Uma linha — o que cultivas, onde', + 'profile.yourId' => 'A tua identidade', + 'profile.idHelp' => 'Partilha-a para que te reconheçam', + 'profile.copy' => 'Copiar', + 'profile.copied' => 'Copiado', + 'profile.save' => 'Guardar', + 'profile.saved' => 'Perfil guardado', 'chatList.title' => 'Mensagens', 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', 'chat.title' => 'Conversa', diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index f7dcce5..18d8421 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -10,6 +10,7 @@ import 'services/coarse_location.dart'; import 'services/message_store.dart'; import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; +import 'services/profile_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -29,6 +30,7 @@ Future main() async { location: const GeolocatorCoarseLocation(), outbox: getIt(), messageStore: getIt(), + profileStore: getIt(), showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() ? getIt() diff --git a/apps/app_seeds/lib/services/profile_store.dart b/apps/app_seeds/lib/services/profile_store.dart new file mode 100644 index 0000000..99e2660 --- /dev/null +++ b/apps/app_seeds/lib/services/profile_store.dart @@ -0,0 +1,20 @@ +import '../security/secret_store.dart'; + +/// Your own display name and short "about", persisted locally (keystore-backed, +/// no plaintext at rest). The network copy is a published NIP-01 kind:0 event; +/// this local copy prefills the editor and works offline. +class ProfileStore { + ProfileStore(this._store); + + final SecretStore _store; + static const _nameKey = 'tane.social.profile.name'; + static const _aboutKey = 'tane.social.profile.about'; + + Future name() async => (await _store.read(_nameKey)) ?? ''; + Future about() async => (await _store.read(_aboutKey)) ?? ''; + + Future save({required String name, required String about}) async { + await _store.write(_nameKey, name.trim()); + await _store.write(_aboutKey, about.trim()); + } +} diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index aee6ea6..87463de 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -51,13 +51,15 @@ class SocialSession { SocialSession(this._channel) : offers = NostrOfferTransport(_channel), messages = NostrMessageTransport(_channel), - trust = NostrTrustTransport(_channel); + trust = NostrTrustTransport(_channel), + profile = NostrProfileTransport(_channel); final NostrChannel _channel; final OfferTransport offers; final MessageTransport messages; final TrustTransport trust; + final ProfileTransport profile; Future close() => _channel.close(); } diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 4bbd463..642c1ed 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -45,7 +45,16 @@ class AppDrawer extends StatelessWidget { } : null, ), - _DrawerItem(icon: const Icon(Icons.person), label: t.menu.profile), + _DrawerItem( + icon: const Icon(Icons.person), + label: t.menu.profile, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.go('/profile'); + } + : null, + ), _DrawerItem( icon: const Icon(Icons.chat_bubble), label: t.menu.chat, diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart new file mode 100644 index 0000000..02cbb32 --- /dev/null +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../i18n/strings.g.dart'; +import '../services/profile_store.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import 'theme.dart'; + +/// Your own profile: the shareable identity (npub) plus a display name and short +/// "about" others see instead of a raw key. Saving persists locally and, when +/// online, publishes NIP-01 kind:0 metadata so peers can recognise you. +class ProfileScreen extends StatefulWidget { + const ProfileScreen({ + required this.social, + required this.settings, + required this.profileStore, + super.key, + }); + + final SocialService social; + final SocialSettings settings; + final ProfileStore profileStore; + + @override + State createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends State { + final _name = TextEditingController(); + final _about = TextEditingController(); + bool _loading = true; + bool _saving = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + _name.text = await widget.profileStore.name(); + _about.text = await widget.profileStore.about(); + if (mounted) setState(() => _loading = false); + } + + Future _copyId() async { + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + await Clipboard.setData(ClipboardData(text: widget.social.npub)); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(t.profile.copied))); + } + + Future _save() async { + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + setState(() => _saving = true); + await widget.profileStore.save(name: _name.text, about: _about.text); + + // Best-effort publish to the network so peers see the name. + final relays = await widget.settings.relayUrls(); + if (relays.isNotEmpty) { + try { + final session = await widget.social.openSession(relays); + await session.profile.publish( + name: _name.text.trim(), + about: _about.text.trim(), + ); + await session.close(); + } catch (_) { + // offline / unreachable — the local copy is saved regardless. + } + } + if (!mounted) return; + setState(() => _saving = false); + messenger.showSnackBar(SnackBar(content: Text(t.profile.saved))); + } + + @override + void dispose() { + _name.dispose(); + _about.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + return Scaffold( + appBar: AppBar(title: Text(t.profile.title)), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(20), + children: [ + _IdentityCard(npub: widget.social.npub, onCopy: _copyId), + const SizedBox(height: 24), + TextField( + key: const Key('profile.name'), + controller: _name, + decoration: InputDecoration( + labelText: t.profile.name, + helperText: t.profile.nameHint, + ), + ), + const SizedBox(height: 16), + TextField( + key: const Key('profile.about'), + controller: _about, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + labelText: t.profile.about, + helperText: t.profile.aboutHint, + ), + ), + const SizedBox(height: 24), + FilledButton( + key: const Key('profile.save'), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(t.profile.save), + ), + ], + ), + ); + } +} + +class _IdentityCard extends StatelessWidget { + const _IdentityCard({required this.npub, required this.onCopy}); + + final String npub; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: seedPrimaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: seedOutline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.profile.yourId, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: seedOnSurface, + ), + ), + const SizedBox(height: 4), + Text( + t.profile.idHelp, + style: const TextStyle(color: seedMuted, fontSize: 12), + ), + const SizedBox(height: 10), + SelectableText( + npub, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: seedOnSurface, + ), + ), + const SizedBox(height: 8), + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton.icon( + key: const Key('profile.copyId'), + onPressed: onCopy, + icon: const Icon(Icons.copy, size: 16), + label: Text(t.profile.copy), + ), + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/test/services/profile_store_test.dart b/apps/app_seeds/test/services/profile_store_test.dart new file mode 100644 index 0000000..6e9b56f --- /dev/null +++ b/apps/app_seeds/test/services/profile_store_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_store.dart'; + +import '../support/test_support.dart'; + +void main() { + late ProfileStore store; + setUp(() => store = ProfileStore(InMemorySecretStore())); + + test('defaults are empty', () async { + expect(await store.name(), ''); + expect(await store.about(), ''); + }); + + test('save then read back, trimmed', () async { + await store.save(name: ' Alicia ', about: ' tomate rosa '); + expect(await store.name(), 'Alicia'); + expect(await store.about(), 'tomate rosa'); + }); +} diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 44fc753..515a513 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -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'; diff --git a/packages/commons_core/lib/src/social/nostr/nostr_profile_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_profile_transport.dart new file mode 100644 index 0000000..62abd79 --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr/nostr_profile_transport.dart @@ -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 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 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; + 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 close() => _conn.close(); +} diff --git a/packages/commons_core/lib/src/social/profile_transport.dart b/packages/commons_core/lib/src/social/profile_transport.dart new file mode 100644 index 0000000..961ad54 --- /dev/null +++ b/packages/commons_core/lib/src/social/profile_transport.dart @@ -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 publish({String name, String about, String picture}); + + /// Fetches [pubkeyHex]'s latest profile, or null if they never published one. + Future fetch(String pubkeyHex); + + Future close(); +} diff --git a/packages/commons_core/test/social/nostr_profile_test.dart b/packages/commons_core/test/social/nostr_profile_test.dart new file mode 100644 index 0000000..49c95ec --- /dev/null +++ b/packages/commons_core/test/social/nostr_profile_test.dart @@ -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 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(); + }); +}