diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 1b7788c..db5133f 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_cache.dart'; import 'services/profile_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -44,6 +45,7 @@ class TaneApp extends StatelessWidget { this.outbox, this.messageStore, this.profileStore, + this.profileCache, this.showIntro = false, this.autoBackup, super.key, @@ -57,6 +59,7 @@ class TaneApp extends StatelessWidget { outbox, messageStore, profileStore, + profileCache, ); final VarietyRepository repository; @@ -79,6 +82,9 @@ class TaneApp extends StatelessWidget { /// Optional local store for your own profile (name/about). final ProfileStore? profileStore; + + /// Optional cache of peers' published display names. + final ProfileCache? profileCache; final bool showIntro; /// Drives silent periodic backups off the app lifecycle. Null in widget tests @@ -96,6 +102,7 @@ class TaneApp extends StatelessWidget { OfferOutbox? outbox, MessageStore? messageStore, ProfileStore? profileStore, + ProfileCache? profileCache, ) { return GoRouter( initialLocation: showIntro ? '/intro' : '/', @@ -118,7 +125,12 @@ class TaneApp extends StatelessWidget { if (social != null && socialSettings != null && messageStore != null) GoRoute( path: '/messages', - builder: (context, state) => ChatListScreen(store: messageStore), + builder: (context, state) => ChatListScreen( + store: messageStore, + social: social, + settings: socialSettings, + profileCache: profileCache, + ), ), if (social != null && socialSettings != null && profileStore != null) GoRoute( @@ -137,6 +149,7 @@ class TaneApp extends StatelessWidget { settings: socialSettings, peerPubkey: state.pathParameters['pubkey']!, messageStore: messageStore, + profileCache: profileCache, ), ), GoRoute( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 47706a9..2cffc8d 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_cache.dart'; import '../services/profile_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -94,6 +95,7 @@ Future configureDependencies() async { ..registerSingleton(OfferOutbox(secretStore)) ..registerSingleton(MessageStore(secretStore)) ..registerSingleton(ProfileStore(secretStore)) + ..registerSingleton(ProfileCache(secretStore)) ..registerSingleton( ExportImportService( repository: varietyRepository, diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 18d8421..f978dfd 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_cache.dart'; import 'services/profile_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -31,6 +32,7 @@ Future main() async { outbox: getIt(), messageStore: getIt(), profileStore: getIt(), + profileCache: getIt(), showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() ? getIt() diff --git a/apps/app_seeds/lib/services/profile_cache.dart b/apps/app_seeds/lib/services/profile_cache.dart new file mode 100644 index 0000000..c403139 --- /dev/null +++ b/apps/app_seeds/lib/services/profile_cache.dart @@ -0,0 +1,25 @@ +import '../security/secret_store.dart'; + +/// Remembers the display names peers have published (their NIP-01 kind:0 +/// `name`), so the inbox and chat show a human name instead of a raw key — +/// even offline. Keystore-backed (no plaintext at rest). +class ProfileCache { + ProfileCache(this._store); + + final SecretStore _store; + static const _prefix = 'tane.social.name.'; + + /// The cached name for [pubkeyHex], or null if none is known. + Future name(String pubkeyHex) async { + final value = await _store.read('$_prefix$pubkeyHex'); + return (value == null || value.isEmpty) ? null : value; + } + + Future setName(String pubkeyHex, String name) => + _store.write('$_prefix$pubkeyHex', name.trim()); +} + +/// A compact, human-ish rendering of a public key when no name is known yet. +String shortPubkey(String pubkeyHex) => pubkeyHex.length <= 12 + ? pubkeyHex + : '${pubkeyHex.substring(0, 6)}…${pubkeyHex.substring(pubkeyHex.length - 4)}'; diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart index e91a716..9d0d320 100644 --- a/apps/app_seeds/lib/ui/chat_list_screen.dart +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -1,16 +1,30 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; +import '../services/profile_cache.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; import 'theme.dart'; -/// The messages inbox: past conversations, newest first. Tapping one opens the -/// chat. Reached from the drawer's "Chat" entry. +/// The messages inbox: past conversations, newest first. Shows each peer's +/// published name (falling back to a short key), and opens the chat on tap. class ChatListScreen extends StatefulWidget { - const ChatListScreen({required this.store, super.key}); + const ChatListScreen({ + required this.store, + this.social, + this.settings, + this.profileCache, + super.key, + }); final MessageStore store; + final SocialService? social; + final SocialSettings? settings; + final ProfileCache? profileCache; @override State createState() => _ChatListScreenState(); @@ -18,6 +32,7 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State { List? _items; + final Map _names = {}; @override void initState() { @@ -27,7 +42,43 @@ class _ChatListScreenState extends State { Future _load() async { final items = await widget.store.conversations(); - if (mounted) setState(() => _items = items); + final cache = widget.profileCache; + if (cache != null) { + for (final c in items) { + final name = await cache.name(c.peerPubkey); + if (name != null) _names[c.peerPubkey] = name; + } + } + if (!mounted) return; + setState(() => _items = items); + unawaited(_resolveMissingNames(items)); + } + + /// Fetches published names for peers we don't have cached yet, over one + /// session. Best effort; the list already shows short keys meanwhile. + Future _resolveMissingNames(List items) async { + final social = widget.social; + final settings = widget.settings; + final cache = widget.profileCache; + if (social == null || settings == null || cache == null) return; + final missing = + items.map((c) => c.peerPubkey).where((p) => !_names.containsKey(p)); + if (missing.isEmpty) return; + final relays = await settings.relayUrls(); + if (relays.isEmpty) return; + try { + final session = await social.openSession(relays); + for (final peer in missing) { + final profile = await session.profile.fetch(peer); + if (profile != null && profile.name.isNotEmpty) { + await cache.setName(peer, profile.name); + if (mounted) setState(() => _names[peer] = profile.name); + } + } + await session.close(); + } catch (_) { + // offline / unreachable — short keys stay. + } } @override @@ -59,7 +110,8 @@ class _ChatListScreenState extends State { backgroundColor: seedAvatar, child: Icon(Icons.person, color: seedOnAvatar), ), - title: Text(_shortId(c.peerPubkey)), + title: Text(_names[c.peerPubkey] ?? + shortPubkey(c.peerPubkey)), subtitle: Text( c.lastText, maxLines: 1, @@ -72,8 +124,3 @@ class _ChatListScreenState extends State { ); } } - -/// A compact, human-ish rendering of a public key until we resolve names. -String _shortId(String pubkey) => pubkey.length <= 12 - ? pubkey - : '${pubkey.substring(0, 6)}…${pubkey.substring(pubkey.length - 4)}'; diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 5f6f8f7..a618656 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; +import '../services/profile_cache.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/messages_cubit.dart'; @@ -20,6 +21,7 @@ class ChatScreen extends StatefulWidget { required this.settings, required this.peerPubkey, this.messageStore, + this.profileCache, super.key, }); @@ -30,6 +32,9 @@ class ChatScreen extends StatefulWidget { /// Optional persistence for chat history (keystore-backed); null in tests. final MessageStore? messageStore; + /// Optional cache of peer display names; null in tests. + final ProfileCache? profileCache; + @override State createState() => _ChatScreenState(); } @@ -39,6 +44,7 @@ class _ChatScreenState extends State { MessagesCubit? _messages; TrustCubit? _trust; bool _loading = true; + String? _peerName; final _input = TextEditingController(); @override @@ -59,6 +65,7 @@ class _ChatScreenState extends State { session = null; } } + final cachedName = await widget.profileCache?.name(widget.peerPubkey); if (!mounted) { await session?.close(); return; @@ -77,8 +84,25 @@ class _ChatScreenState extends State { _session = session; _messages = messages; _trust = trust; + _peerName = cachedName; _loading = false; }); + + // Freshen the peer's name from their published profile. + final live = session; + if (live != null && widget.profileCache != null) { + unawaited(() async { + try { + final profile = await live.profile.fetch(widget.peerPubkey); + if (profile != null && profile.name.isNotEmpty && mounted) { + await widget.profileCache!.setName(widget.peerPubkey, profile.name); + setState(() => _peerName = profile.name); + } + } catch (_) { + // best effort + } + }()); + } } Future _send() async { @@ -100,11 +124,12 @@ class _ChatScreenState extends State { @override Widget build(BuildContext context) { - final t = context.t; final messages = _messages; final trust = _trust; return Scaffold( - appBar: AppBar(title: Text(t.chat.title)), + appBar: AppBar( + title: Text(_peerName ?? shortPubkey(widget.peerPubkey)), + ), body: _loading || messages == null || trust == null ? const Center(child: CircularProgressIndicator()) : MultiBlocProvider( diff --git a/apps/app_seeds/test/services/profile_cache_test.dart b/apps/app_seeds/test/services/profile_cache_test.dart new file mode 100644 index 0000000..cd62c7b --- /dev/null +++ b/apps/app_seeds/test/services/profile_cache_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/profile_cache.dart'; + +import '../support/test_support.dart'; + +void main() { + test('unknown pubkey has no cached name', () async { + final cache = ProfileCache(InMemorySecretStore()); + expect(await cache.name('abc'), isNull); + }); + + test('setName then name round-trips (trimmed)', () async { + final cache = ProfileCache(InMemorySecretStore()); + await cache.setName('abc', ' Alicia '); + expect(await cache.name('abc'), 'Alicia'); + }); + + test('shortPubkey abbreviates long keys, keeps short ones', () { + expect(shortPubkey('abcdef1234567890'), 'abcdef…7890'); + expect(shortPubkey('short'), 'short'); + }); +}