Merge branch 'spike/block2-derisking'

This commit is contained in:
vjrj 2026-07-10 12:57:02 +02:00
commit 5b59670c47
7 changed files with 149 additions and 13 deletions

View file

@ -12,6 +12,7 @@ import 'services/coarse_location.dart';
import 'services/message_store.dart'; import 'services/message_store.dart';
import 'services/offer_outbox.dart'; import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart'; import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
@ -44,6 +45,7 @@ class TaneApp extends StatelessWidget {
this.outbox, this.outbox,
this.messageStore, this.messageStore,
this.profileStore, this.profileStore,
this.profileCache,
this.showIntro = false, this.showIntro = false,
this.autoBackup, this.autoBackup,
super.key, super.key,
@ -57,6 +59,7 @@ class TaneApp extends StatelessWidget {
outbox, outbox,
messageStore, messageStore,
profileStore, profileStore,
profileCache,
); );
final VarietyRepository repository; final VarietyRepository repository;
@ -79,6 +82,9 @@ class TaneApp extends StatelessWidget {
/// Optional local store for your own profile (name/about). /// Optional local store for your own profile (name/about).
final ProfileStore? profileStore; final ProfileStore? profileStore;
/// Optional cache of peers' published display names.
final ProfileCache? profileCache;
final bool showIntro; final bool showIntro;
/// Drives silent periodic backups off the app lifecycle. Null in widget tests /// Drives silent periodic backups off the app lifecycle. Null in widget tests
@ -96,6 +102,7 @@ class TaneApp extends StatelessWidget {
OfferOutbox? outbox, OfferOutbox? outbox,
MessageStore? messageStore, MessageStore? messageStore,
ProfileStore? profileStore, ProfileStore? profileStore,
ProfileCache? profileCache,
) { ) {
return GoRouter( return GoRouter(
initialLocation: showIntro ? '/intro' : '/', initialLocation: showIntro ? '/intro' : '/',
@ -118,7 +125,12 @@ class TaneApp extends StatelessWidget {
if (social != null && socialSettings != null && messageStore != null) if (social != null && socialSettings != null && messageStore != null)
GoRoute( GoRoute(
path: '/messages', 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) if (social != null && socialSettings != null && profileStore != null)
GoRoute( GoRoute(
@ -137,6 +149,7 @@ class TaneApp extends StatelessWidget {
settings: socialSettings, settings: socialSettings,
peerPubkey: state.pathParameters['pubkey']!, peerPubkey: state.pathParameters['pubkey']!,
messageStore: messageStore, messageStore: messageStore,
profileCache: profileCache,
), ),
), ),
GoRoute( GoRoute(

View file

@ -29,6 +29,7 @@ import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart'; import '../services/share_catalog_service.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/profile_cache.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
@ -94,6 +95,7 @@ Future<void> configureDependencies() async {
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore)) ..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
..registerSingleton<MessageStore>(MessageStore(secretStore)) ..registerSingleton<MessageStore>(MessageStore(secretStore))
..registerSingleton<ProfileStore>(ProfileStore(secretStore)) ..registerSingleton<ProfileStore>(ProfileStore(secretStore))
..registerSingleton<ProfileCache>(ProfileCache(secretStore))
..registerSingleton<ExportImportService>( ..registerSingleton<ExportImportService>(
ExportImportService( ExportImportService(
repository: varietyRepository, repository: varietyRepository,

View file

@ -10,6 +10,7 @@ import 'services/coarse_location.dart';
import 'services/message_store.dart'; import 'services/message_store.dart';
import 'services/offer_outbox.dart'; import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart'; import 'services/onboarding_store.dart';
import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
@ -31,6 +32,7 @@ Future<void> main() async {
outbox: getIt<OfferOutbox>(), outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(), messageStore: getIt<MessageStore>(),
profileStore: getIt<ProfileStore>(), profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(),
showIntro: !await onboarding.introSeen(), showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered<AutoBackupService>() autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>() ? getIt<AutoBackupService>()

View file

@ -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<String?> name(String pubkeyHex) async {
final value = await _store.read('$_prefix$pubkeyHex');
return (value == null || value.isEmpty) ? null : value;
}
Future<void> 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)}';

View file

@ -1,16 +1,30 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/profile_cache.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import 'theme.dart'; import 'theme.dart';
/// The messages inbox: past conversations, newest first. Tapping one opens the /// The messages inbox: past conversations, newest first. Shows each peer's
/// chat. Reached from the drawer's "Chat" entry. /// published name (falling back to a short key), and opens the chat on tap.
class ChatListScreen extends StatefulWidget { 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 MessageStore store;
final SocialService? social;
final SocialSettings? settings;
final ProfileCache? profileCache;
@override @override
State<ChatListScreen> createState() => _ChatListScreenState(); State<ChatListScreen> createState() => _ChatListScreenState();
@ -18,6 +32,7 @@ class ChatListScreen extends StatefulWidget {
class _ChatListScreenState extends State<ChatListScreen> { class _ChatListScreenState extends State<ChatListScreen> {
List<ChatSummary>? _items; List<ChatSummary>? _items;
final Map<String, String> _names = {};
@override @override
void initState() { void initState() {
@ -27,7 +42,43 @@ class _ChatListScreenState extends State<ChatListScreen> {
Future<void> _load() async { Future<void> _load() async {
final items = await widget.store.conversations(); 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<void> _resolveMissingNames(List<ChatSummary> 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 @override
@ -59,7 +110,8 @@ class _ChatListScreenState extends State<ChatListScreen> {
backgroundColor: seedAvatar, backgroundColor: seedAvatar,
child: Icon(Icons.person, color: seedOnAvatar), child: Icon(Icons.person, color: seedOnAvatar),
), ),
title: Text(_shortId(c.peerPubkey)), title: Text(_names[c.peerPubkey] ??
shortPubkey(c.peerPubkey)),
subtitle: Text( subtitle: Text(
c.lastText, c.lastText,
maxLines: 1, maxLines: 1,
@ -72,8 +124,3 @@ class _ChatListScreenState extends State<ChatListScreen> {
); );
} }
} }
/// 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)}';

View file

@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/profile_cache.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import '../state/messages_cubit.dart'; import '../state/messages_cubit.dart';
@ -20,6 +21,7 @@ class ChatScreen extends StatefulWidget {
required this.settings, required this.settings,
required this.peerPubkey, required this.peerPubkey,
this.messageStore, this.messageStore,
this.profileCache,
super.key, super.key,
}); });
@ -30,6 +32,9 @@ class ChatScreen extends StatefulWidget {
/// Optional persistence for chat history (keystore-backed); null in tests. /// Optional persistence for chat history (keystore-backed); null in tests.
final MessageStore? messageStore; final MessageStore? messageStore;
/// Optional cache of peer display names; null in tests.
final ProfileCache? profileCache;
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
} }
@ -39,6 +44,7 @@ class _ChatScreenState extends State<ChatScreen> {
MessagesCubit? _messages; MessagesCubit? _messages;
TrustCubit? _trust; TrustCubit? _trust;
bool _loading = true; bool _loading = true;
String? _peerName;
final _input = TextEditingController(); final _input = TextEditingController();
@override @override
@ -59,6 +65,7 @@ class _ChatScreenState extends State<ChatScreen> {
session = null; session = null;
} }
} }
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
if (!mounted) { if (!mounted) {
await session?.close(); await session?.close();
return; return;
@ -77,8 +84,25 @@ class _ChatScreenState extends State<ChatScreen> {
_session = session; _session = session;
_messages = messages; _messages = messages;
_trust = trust; _trust = trust;
_peerName = cachedName;
_loading = false; _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<void> _send() async { Future<void> _send() async {
@ -100,11 +124,12 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.t;
final messages = _messages; final messages = _messages;
final trust = _trust; final trust = _trust;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(t.chat.title)), appBar: AppBar(
title: Text(_peerName ?? shortPubkey(widget.peerPubkey)),
),
body: _loading || messages == null || trust == null body: _loading || messages == null || trust == null
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: MultiBlocProvider( : MultiBlocProvider(

View file

@ -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');
});
}