Merge branch 'spike/block2-derisking'
This commit is contained in:
commit
5b59670c47
7 changed files with 149 additions and 13 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
|
||||||
25
apps/app_seeds/lib/services/profile_cache.dart
Normal file
25
apps/app_seeds/lib/services/profile_cache.dart
Normal 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)}';
|
||||||
|
|
@ -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)}';
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
22
apps/app_seeds/test/services/profile_cache_test.dart
Normal file
22
apps/app_seeds/test/services/profile_cache_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue