Merge branch 'spike/block2-derisking'

This commit is contained in:
vjrj 2026-07-10 12:20:52 +02:00
commit bd6938c62d
18 changed files with 308 additions and 150 deletions

View file

@ -9,6 +9,7 @@ import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'services/auto_backup_service.dart';
import 'services/coarse_location.dart';
import 'services/message_store.dart';
import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart';
import 'services/social_service.dart';
@ -38,6 +39,7 @@ class TaneApp extends StatelessWidget {
this.socialSettings,
this.location,
this.outbox,
this.messageStore,
this.showIntro = false,
this.autoBackup,
super.key,
@ -49,6 +51,7 @@ class TaneApp extends StatelessWidget {
socialSettings,
location,
outbox,
messageStore,
);
final VarietyRepository repository;
@ -65,6 +68,9 @@ class TaneApp extends StatelessWidget {
/// Optional offline outbox for the market's "share my seeds".
final OfferOutbox? outbox;
/// Optional persistence for chat history.
final MessageStore? messageStore;
final bool showIntro;
/// Drives silent periodic backups off the app lifecycle. Null in widget tests
@ -80,6 +86,7 @@ class TaneApp extends StatelessWidget {
SocialSettings? socialSettings,
CoarseLocationProvider? location,
OfferOutbox? outbox,
MessageStore? messageStore,
) {
return GoRouter(
initialLocation: showIntro ? '/intro' : '/',
@ -106,6 +113,7 @@ class TaneApp extends StatelessWidget {
social: social,
settings: socialSettings,
peerPubkey: state.pathParameters['pubkey']!,
messageStore: messageStore,
),
),
GoRoute(

View file

@ -27,6 +27,7 @@ import '../services/ocr/tesseract_label_extractor.dart';
import '../services/onboarding_store.dart';
import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart';
import '../services/message_store.dart';
import '../services/offer_outbox.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
@ -90,6 +91,7 @@ Future<void> configureDependencies() async {
..registerSingleton<SocialService>(socialService)
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
..registerSingleton<MessageStore>(MessageStore(secretStore))
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,

View file

@ -372,6 +372,7 @@
"none": "No one vouches for them yet",
"count": "Vouched for by {n}",
"vouch": "I know this person",
"vouched": "You vouch for them"
"vouched": "You vouch for them",
"circle": "In your circle"
}
}

View file

@ -372,6 +372,7 @@
"none": "Nadie los avala aún",
"count": "Avalada por {n}",
"vouch": "Conozco a esta persona",
"vouched": "Avalas a esta persona"
"vouched": "Avalas a esta persona",
"circle": "En tu círculo"
}
}

View file

@ -368,6 +368,7 @@
"none": "Ainda ninguém os avaliza",
"count": "Avalizada por {n}",
"vouch": "Conheço esta pessoa",
"vouched": "Avalizas esta pessoa"
"vouched": "Avalizas esta pessoa",
"circle": "No teu círculo"
}
}

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 3
/// Strings: 989 (329 per locale)
/// Strings: 992 (330 per locale)
///
/// Built on 2026-07-10 at 09:34 UTC
/// Built on 2026-07-10 at 10:09 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -1185,6 +1185,9 @@ class Translations$trust$en {
/// en: 'You vouch for them'
String get vouched => 'You vouch for them';
/// en: 'In your circle'
String get circle => 'In your circle';
}
// Path: intro.slides
@ -1975,6 +1978,7 @@ extension on Translations {
'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them',
'trust.circle' => 'In your circle',
_ => null,
};
}

View file

@ -659,6 +659,7 @@ class _Translations$trust$es extends Translations$trust$en {
@override String count({required Object n}) => 'Avalada por ${n}';
@override String get vouch => 'Conozco a esta persona';
@override String get vouched => 'Avalas a esta persona';
@override String get circle => 'En tu círculo';
}
// Path: intro.slides
@ -1333,6 +1334,7 @@ extension on TranslationsEs {
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozco a esta persona',
'trust.vouched' => 'Avalas a esta persona',
'trust.circle' => 'En tu círculo',
_ => null,
};
}

View file

@ -655,6 +655,7 @@ class _Translations$trust$pt extends Translations$trust$en {
@override String count({required Object n}) => 'Avalizada por ${n}';
@override String get vouch => 'Conheço esta pessoa';
@override String get vouched => 'Avalizas esta pessoa';
@override String get circle => 'No teu círculo';
}
// Path: intro.slides
@ -1325,6 +1326,7 @@ extension on TranslationsPt {
'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
'trust.vouch' => 'Conheço esta pessoa',
'trust.vouched' => 'Avalizas esta pessoa',
'trust.circle' => 'No teu círculo',
_ => null,
};
}

View file

@ -7,6 +7,7 @@ import 'di/injector.dart';
import 'i18n/strings.g.dart';
import 'services/auto_backup_service.dart';
import 'services/coarse_location.dart';
import 'services/message_store.dart';
import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart';
import 'services/social_service.dart';
@ -27,6 +28,7 @@ Future<void> main() async {
socialSettings: getIt<SocialSettings>(),
location: const GeolocatorCoarseLocation(),
outbox: getIt<OfferOutbox>(),
messageStore: getIt<MessageStore>(),
showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()

View file

@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:commons_core/commons_core.dart';
import '../security/secret_store.dart';
/// Persists a 1:1 chat history, keystore-backed (so no plaintext at rest). A
/// capped, per-peer JSON list recent history survives leaving the chat. Small
/// and simple on purpose; the encrypted Drift DB is the eventual home if chats
/// grow large.
class MessageStore {
MessageStore(this._store);
final SecretStore _store;
static const _prefix = 'tane.social.chat.';
/// Keep only the most recent [_cap] messages per conversation, to bound the
/// keystore entry size.
static const _cap = 200;
String _key(String peerPubkey) => '$_prefix$peerPubkey';
/// Messages exchanged with [peerPubkey], oldest first.
Future<List<PrivateMessage>> history(String peerPubkey) async {
final raw = await _store.read(_key(peerPubkey));
if (raw == null || raw.isEmpty) return const [];
final list = jsonDecode(raw) as List;
return [
for (final m in list.cast<Map<String, dynamic>>())
PrivateMessage(
fromPubkey: m['from'] as String,
text: m['text'] as String,
at: DateTime.fromMillisecondsSinceEpoch(m['at'] as int),
),
];
}
/// Appends [message] to the conversation with [peerPubkey] (trimming to the
/// cap).
Future<void> append(String peerPubkey, PrivateMessage message) async {
final next = [...await history(peerPubkey), message];
final capped =
next.length > _cap ? next.sublist(next.length - _cap) : next;
await _store.write(
_key(peerPubkey),
jsonEncode([
for (final m in capped)
{
'from': m.fromPubkey,
'text': m.text,
'at': m.at.millisecondsSinceEpoch,
},
]),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/message_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
@ -46,11 +47,14 @@ class MessagesCubit extends Cubit<ChatState> {
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
MessageStore? store,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose,
}) : _store = store,
_onDispose = onDispose,
super(const ChatState());
final MessageTransport? _transport;
final MessageStore? _store;
final String peerPubkey;
final String selfPubkey;
final Future<void> Function()? _onDispose;
@ -58,17 +62,26 @@ class MessagesCubit extends Cubit<ChatState> {
bool get isOnline => _transport != null;
/// Subscribes to incoming messages from [peerPubkey].
void start() {
/// Subscribes to incoming messages, then loads any saved history. Subscribing
/// first (before the async history load) avoids dropping an event that arrives
/// during the load.
Future<void> start() async {
final transport = _transport;
if (transport == null) return;
_sub = transport.inbox().listen(
(message) {
if (message.fromPubkey != peerPubkey) return; // another conversation
emit(state.copyWith(messages: [...state.messages, message]));
},
onError: (Object e) => emit(state.copyWith(error: () => '$e')),
);
if (transport != null) {
_sub = transport.inbox().listen(
(message) async {
if (message.fromPubkey != peerPubkey) return; // another conversation
await _store?.append(peerPubkey, message);
emit(state.copyWith(messages: [...state.messages, message]));
},
onError: (Object e) => emit(state.copyWith(error: () => '$e')),
);
}
// History is older than anything that arrives now, so prepend it.
final history = await _store?.history(peerPubkey);
if (history != null && history.isNotEmpty) {
emit(state.copyWith(messages: [...history, ...state.messages]));
}
}
/// Sends [text] to the peer and appends it optimistically.
@ -79,16 +92,15 @@ class MessagesCubit extends Cubit<ChatState> {
emit(state.copyWith(sending: true, error: () => null));
try {
await transport.send(toPubkey: peerPubkey, text: trimmed);
final mine = PrivateMessage(
fromPubkey: selfPubkey,
text: trimmed,
at: DateTime.now(),
);
await _store?.append(peerPubkey, mine);
emit(state.copyWith(
sending: false,
messages: [
...state.messages,
PrivateMessage(
fromPubkey: selfPubkey,
text: trimmed,
at: DateTime.now(),
),
],
messages: [...state.messages, mine],
));
} catch (e) {
emit(state.copyWith(sending: false, error: () => '$e'));

View file

@ -14,30 +14,43 @@ class TrustState extends Equatable {
const TrustState({
this.certifierCount = 0,
this.iVouch = false,
this.knownToYou = false,
this.loading = true,
this.busy = false,
});
/// How many people (network-wide) vouch for the peer.
final int certifierCount;
/// Whether this user vouches for the peer.
final bool iVouch;
/// Whether the peer is within the user's own circle of trust — you vouch for
/// them, or someone you vouch for does (friend-of-a-friend). This is the
/// meaningful signal; a raw network count invites spam.
final bool knownToYou;
final bool loading;
final bool busy;
TrustState copyWith({
int? certifierCount,
bool? iVouch,
bool? knownToYou,
bool? loading,
bool? busy,
}) =>
TrustState(
certifierCount: certifierCount ?? this.certifierCount,
iVouch: iVouch ?? this.iVouch,
knownToYou: knownToYou ?? this.knownToYou,
loading: loading ?? this.loading,
busy: busy ?? this.busy,
);
@override
List<Object?> get props => [certifierCount, iVouch, loading, busy];
List<Object?> get props =>
[certifierCount, iVouch, knownToYou, loading, busy];
}
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport].
@ -57,7 +70,13 @@ class TrustCubit extends Cubit<TrustState> {
bool get isOnline => _transport != null;
/// Loads the current certifiers of the peer.
/// Bootstrap "circle" rule: one vouch from your side, out to a friend-of-a-
/// friend. Deliberately loose (the full member policy threshold/distance
/// is still open, network-trust.md §2); this is just "people near you".
static const _circleThreshold = 1;
static const _circleDistance = 2;
/// Loads the peer's certifiers and whether they're within your circle.
Future<void> load() async {
final transport = _transport;
if (transport == null) {
@ -65,10 +84,20 @@ class TrustCubit extends Cubit<TrustState> {
return;
}
emit(state.copyWith(loading: true));
final certifiers = await transport.certifiersOf(peerPubkey);
final wot = WebOfTrust.fromCertifications(
await transport.allCertifications(),
now: DateTime.now(),
);
final certifiers = wot.certifiersOf(peerPubkey);
final circle = wot.members(
seeds: {selfPubkey},
threshold: _circleThreshold,
maxDistance: _circleDistance,
);
emit(state.copyWith(
certifierCount: certifiers.length,
iVouch: certifiers.contains(selfPubkey),
knownToYou: circle.contains(peerPubkey),
loading: false,
));
}

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../i18n/strings.g.dart';
import '../services/message_store.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../state/messages_cubit.dart';
@ -18,6 +19,7 @@ class ChatScreen extends StatefulWidget {
required this.social,
required this.settings,
required this.peerPubkey,
this.messageStore,
super.key,
});
@ -25,6 +27,9 @@ class ChatScreen extends StatefulWidget {
final SocialSettings settings;
final String peerPubkey;
/// Optional persistence for chat history (keystore-backed); null in tests.
final MessageStore? messageStore;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
@ -59,9 +64,12 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
final self = widget.social.publicKeyHex;
final messages = MessagesCubit(session?.messages,
peerPubkey: widget.peerPubkey, selfPubkey: self)
..start();
final messages = MessagesCubit(
session?.messages,
peerPubkey: widget.peerPubkey,
selfPubkey: self,
store: widget.messageStore,
)..start();
final trust = TrustCubit(session?.trust,
peerPubkey: widget.peerPubkey, selfPubkey: self);
unawaited(trust.load());
@ -173,20 +181,31 @@ class _TrustBanner extends StatelessWidget {
builder: (context, state) {
final cubit = context.read<TrustCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
final known = state.knownToYou;
return Container(
width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.verified_user_outlined, size: 18, color: seedGreen),
Icon(
known ? Icons.verified : Icons.verified_user_outlined,
size: 18,
color: seedGreen,
),
const SizedBox(width: 8),
Expanded(
child: Text(
state.certifierCount == 0
? t.trust.none
: t.trust.count(n: state.certifierCount),
style: const TextStyle(color: seedOnSurface, fontSize: 13),
known
? t.trust.circle
: state.certifierCount == 0
? t.trust.none
: t.trust.count(n: state.certifierCount),
style: TextStyle(
color: known ? seedGreen : seedOnSurface,
fontSize: 13,
fontWeight: known ? FontWeight.w600 : FontWeight.w400,
),
),
),
TextButton(