From 7cba4f7fcfdf7ebc0a0365ec8cfbb295d55ec2f9 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 11:04:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(block2):=20trust=20UI=20=E2=80=94=20vouch?= =?UTF-8?q?=20for=20a=20person=20in=20chat=20(web=20of=20trust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrustCubit: reads how many vouch for the peer + whether YOU do; toggleVouch certifies/revokes over TrustTransport; never vouches for self; offline-safe. Kept simple — the full known-member rule (threshold+distance) and its bootstrap set are still-open policy (network-trust §2); a live count + your vouch is the honest first step. - Chat screen now opens ONE session carrying both messaging AND trust (the shared-connection shape), and shows a slim vouch banner: 'Vouched for by N' + an 'I know this person' / 'You vouch for them' toggle. - i18n en/es/pt (human words: 'I know this person', not 'certify'). Tests: 4 trust cubit + messaging + chat, 10 green; analyzer clean. --- apps/app_seeds/lib/i18n/en.i18n.json | 6 + apps/app_seeds/lib/i18n/es.i18n.json | 6 + apps/app_seeds/lib/i18n/pt.i18n.json | 6 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 26 ++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 18 +++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 18 +++ apps/app_seeds/lib/state/trust_cubit.dart | 121 ++++++++++++++++++ apps/app_seeds/lib/ui/chat_screen.dart | 97 +++++++++++--- .../test/state/trust_cubit_test.dart | 82 ++++++++++++ 10 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 apps/app_seeds/lib/state/trust_cubit.dart create mode 100644 apps/app_seeds/test/state/trust_cubit_test.dart diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 0bd284b..5feeae6 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -366,5 +366,11 @@ "send": "Send", "empty": "No messages yet — say hello", "offline": "Set up sharing to send messages" + }, + "trust": { + "none": "No one vouches for them yet", + "count": "Vouched for by {n}", + "vouch": "I know this person", + "vouched": "You vouch for them" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 3dfc41a..62e5aa9 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -366,5 +366,11 @@ "send": "Enviar", "empty": "Aún no hay mensajes — saluda", "offline": "Configura el compartir para enviar mensajes" + }, + "trust": { + "none": "Nadie los avala aún", + "count": "Avalada por {n}", + "vouch": "Conozco a esta persona", + "vouched": "Avalas a esta persona" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 047786a..5e5dbf8 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -362,5 +362,11 @@ "send": "Enviar", "empty": "Ainda não há mensagens — diz olá", "offline": "Configura a partilha para enviar mensagens" + }, + "trust": { + "none": "Ainda ninguém os avaliza", + "count": "Avalizada por {n}", + "vouch": "Conheço esta pessoa", + "vouched": "Avalizas esta pessoa" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 4236067..77491c8 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: 974 (324 per locale) +/// Strings: 986 (328 per locale) /// -/// Built on 2026-07-10 at 08:58 UTC +/// Built on 2026-07-10 at 09:03 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 9b6cdc5..dc6c5e6 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -71,6 +71,7 @@ class Translations with BaseTranslations { 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$chat$en chat = Translations$chat$en.internal(_root); + late final Translations$trust$en trust = Translations$trust$en.internal(_root); } // Path: app @@ -1162,6 +1163,27 @@ class Translations$chat$en { String get offline => 'Set up sharing to send messages'; } +// Path: trust +class Translations$trust$en { + Translations$trust$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'No one vouches for them yet' + String get none => 'No one vouches for them yet'; + + /// en: 'Vouched for by {n}' + String count({required Object n}) => 'Vouched for by ${n}'; + + /// en: 'I know this person' + String get vouch => 'I know this person'; + + /// en: 'You vouch for them' + String get vouched => 'You vouch for them'; +} + // Path: intro.slides class Translations$intro$slides$en { Translations$intro$slides$en.internal(this._root); @@ -1945,6 +1967,10 @@ extension on Translations { 'chat.send' => 'Send', 'chat.empty' => 'No messages yet — say hello', 'chat.offline' => 'Set up sharing to send messages', + 'trust.none' => 'No one vouches for them yet', + 'trust.count' => ({required Object n}) => 'Vouched for by ${n}', + 'trust.vouch' => 'I know this person', + 'trust.vouched' => 'You vouch for them', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index bda48c2..a36d6b5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -70,6 +70,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Configura el compartir para enviar mensajes'; } +// Path: trust +class _Translations$trust$es extends Translations$trust$en { + _Translations$trust$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get none => 'Nadie los avala aún'; + @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'; +} + // Path: intro.slides class _Translations$intro$slides$es extends Translations$intro$slides$en { _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1313,6 +1327,10 @@ extension on TranslationsEs { 'chat.send' => 'Enviar', 'chat.empty' => 'Aún no hay mensajes — saluda', 'chat.offline' => 'Configura el compartir para enviar mensajes', + 'trust.none' => 'Nadie los avala aún', + 'trust.count' => ({required Object n}) => 'Avalada por ${n}', + 'trust.vouch' => 'Conozco a esta persona', + 'trust.vouched' => 'Avalas a esta persona', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 9e7ae77..ae63640 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -70,6 +70,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Configura a partilha para enviar mensagens'; } +// Path: trust +class _Translations$trust$pt extends Translations$trust$en { + _Translations$trust$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get none => 'Ainda ninguém os avaliza'; + @override String count({required Object n}) => 'Avalizada por ${n}'; + @override String get vouch => 'Conheço esta pessoa'; + @override String get vouched => 'Avalizas esta pessoa'; +} + // Path: intro.slides class _Translations$intro$slides$pt extends Translations$intro$slides$en { _Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1305,6 +1319,10 @@ extension on TranslationsPt { 'chat.send' => 'Enviar', 'chat.empty' => 'Ainda não há mensagens — diz olá', 'chat.offline' => 'Configura a partilha para enviar mensagens', + 'trust.none' => 'Ainda ninguém os avaliza', + 'trust.count' => ({required Object n}) => 'Avalizada por ${n}', + 'trust.vouch' => 'Conheço esta pessoa', + 'trust.vouched' => 'Avalizas esta pessoa', _ => null, }; } diff --git a/apps/app_seeds/lib/state/trust_cubit.dart b/apps/app_seeds/lib/state/trust_cubit.dart new file mode 100644 index 0000000..998deb9 --- /dev/null +++ b/apps/app_seeds/lib/state/trust_cubit.dart @@ -0,0 +1,121 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../services/social_service.dart'; +import '../services/social_settings.dart'; + +/// Trust standing of one peer: how many people vouch for them, and whether YOU +/// do. Kept simple on purpose — the full "known member" rule (threshold + +/// distance) and its bootstrap set are a policy decision still open +/// (network-trust.md §2). Vouching + a live count is the useful, honest first +/// step. +class TrustState extends Equatable { + const TrustState({ + this.certifierCount = 0, + this.iVouch = false, + this.loading = true, + this.busy = false, + }); + + final int certifierCount; + final bool iVouch; + final bool loading; + final bool busy; + + TrustState copyWith({ + int? certifierCount, + bool? iVouch, + bool? loading, + bool? busy, + }) => + TrustState( + certifierCount: certifierCount ?? this.certifierCount, + iVouch: iVouch ?? this.iVouch, + loading: loading ?? this.loading, + busy: busy ?? this.busy, + ); + + @override + List get props => [certifierCount, iVouch, loading, busy]; +} + +/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport]. +class TrustCubit extends Cubit { + TrustCubit( + this._transport, { + required this.peerPubkey, + required this.selfPubkey, + Future Function()? onDispose, + }) : _onDispose = onDispose, + super(const TrustState()); + + final TrustTransport? _transport; + final String peerPubkey; + final String selfPubkey; + final Future Function()? _onDispose; + + bool get isOnline => _transport != null; + + /// Loads the current certifiers of the peer. + Future load() async { + final transport = _transport; + if (transport == null) { + emit(state.copyWith(loading: false)); + return; + } + emit(state.copyWith(loading: true)); + final certifiers = await transport.certifiersOf(peerPubkey); + emit(state.copyWith( + certifierCount: certifiers.length, + iVouch: certifiers.contains(selfPubkey), + loading: false, + )); + } + + /// Adds or removes this user's vouch, then reloads. Never vouches for self. + Future toggleVouch() async { + final transport = _transport; + if (transport == null || peerPubkey == selfPubkey) return; + emit(state.copyWith(busy: true)); + if (state.iVouch) { + await transport.revoke(subjectPubkey: peerPubkey); + } else { + await transport.certify(subjectPubkey: peerPubkey); + } + await load(); + emit(state.copyWith(busy: false)); + } + + @override + Future close() async { + await _onDispose?.call(); + return super.close(); + } +} + +/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an +/// offline one. +Future createTrustCubit( + SocialService social, + SocialSettings settings, { + required String peerPubkey, +}) async { + final relays = await settings.relayUrls(); + if (relays.isEmpty) { + return TrustCubit(null, + peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); + } + try { + final session = await social.openSession(relays); + return TrustCubit( + session.trust, + peerPubkey: peerPubkey, + selfPubkey: social.publicKeyHex, + onDispose: session.close, + ); + } catch (_) { + return TrustCubit(null, + peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex); + } +} diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index ce42515..cc29932 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -5,6 +7,7 @@ import '../i18n/strings.g.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/messages_cubit.dart'; +import '../state/trust_cubit.dart'; import 'theme.dart'; /// A 1:1 encrypted chat with one peer (redesign screen 10). Private and @@ -27,7 +30,9 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { - MessagesCubit? _cubit; + SocialSession? _session; + MessagesCubit? _messages; + TrustCubit? _trust; bool _loading = true; final _input = TextEditingController(); @@ -38,24 +43,38 @@ class _ChatScreenState extends State { } Future _init() async { - final cubit = await createMessagesCubit( - widget.social, - widget.settings, - peerPubkey: widget.peerPubkey, - ); + // One session for this chat carries both messaging and trust (the shared- + // connection shape). Offline (no relays / unreachable) → null transports. + final relays = await widget.settings.relayUrls(); + SocialSession? session; + if (relays.isNotEmpty) { + try { + session = await widget.social.openSession(relays); + } catch (_) { + session = null; + } + } if (!mounted) { - await cubit.close(); + await session?.close(); return; } - cubit.start(); + final self = widget.social.publicKeyHex; + final messages = MessagesCubit(session?.messages, + peerPubkey: widget.peerPubkey, selfPubkey: self) + ..start(); + final trust = TrustCubit(session?.trust, + peerPubkey: widget.peerPubkey, selfPubkey: self); + unawaited(trust.load()); setState(() { - _cubit = cubit; + _session = session; + _messages = messages; + _trust = trust; _loading = false; }); } Future _send() async { - final cubit = _cubit; + final cubit = _messages; if (cubit == null || _input.text.trim().isEmpty) return; final text = _input.text; _input.clear(); @@ -64,7 +83,9 @@ class _ChatScreenState extends State { @override void dispose() { - _cubit?.close(); + _messages?.close(); + _trust?.close(); + _session?.close(); _input.dispose(); super.dispose(); } @@ -72,13 +93,17 @@ class _ChatScreenState extends State { @override Widget build(BuildContext context) { final t = context.t; - final cubit = _cubit; + final messages = _messages; + final trust = _trust; return Scaffold( appBar: AppBar(title: Text(t.chat.title)), - body: _loading || cubit == null + body: _loading || messages == null || trust == null ? const Center(child: CircularProgressIndicator()) - : BlocProvider.value( - value: cubit, + : MultiBlocProvider( + providers: [ + BlocProvider.value(value: messages), + BlocProvider.value(value: trust), + ], child: _ChatBody(controller: _input, onSend: _send), ), ); @@ -109,6 +134,7 @@ class _ChatBody extends StatelessWidget { } return Column( children: [ + const _TrustBanner(), Expanded( child: BlocBuilder( builder: (context, state) { @@ -135,6 +161,47 @@ class _ChatBody extends StatelessWidget { } } +/// A slim "web of trust" strip: how many vouch for this peer, and a toggle to +/// add/remove your own vouch ("I know this person"). +class _TrustBanner extends StatelessWidget { + const _TrustBanner(); + + @override + Widget build(BuildContext context) { + final t = context.t; + return BlocBuilder( + builder: (context, state) { + final cubit = context.read(); + if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); + 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), + 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), + ), + ), + TextButton( + key: const Key('chat.vouch'), + onPressed: state.busy ? null : cubit.toggleVouch, + child: Text(state.iVouch ? t.trust.vouched : t.trust.vouch), + ), + ], + ), + ); + }, + ); + } +} + class _Bubble extends StatelessWidget { const _Bubble({required this.text, required this.mine}); diff --git a/apps/app_seeds/test/state/trust_cubit_test.dart b/apps/app_seeds/test/state/trust_cubit_test.dart new file mode 100644 index 0000000..bf87e7c --- /dev/null +++ b/apps/app_seeds/test/state/trust_cubit_test.dart @@ -0,0 +1,82 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/state/trust_cubit.dart'; + +/// In-memory [TrustTransport]: certify/revoke act as this device's identity. +class FakeTrustTransport implements TrustTransport { + FakeTrustTransport(this.selfId); + final String selfId; + final Map> _certs = {}; + + @override + Future> certifiersOf(String subjectPubkey) async => + _certs[subjectPubkey] ?? {}; + + @override + Future certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) async => + (_certs[subjectPubkey] ??= {}).add(selfId); + + @override + Future revoke({required String subjectPubkey}) async => + _certs[subjectPubkey]?.remove(selfId); + + @override + Future> allCertifications() async => const []; + + @override + Future close() async {} +} + +void main() { + const me = 'me'; + const peer = 'peer'; + + test('loads certifier count and whether I vouch', () async { + final transport = FakeTrustTransport(me); + await transport.certify(subjectPubkey: peer); // I already vouch + final cubit = TrustCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.certifierCount, 1); + expect(cubit.state.iVouch, isTrue); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); + + test('toggleVouch certifies then revokes', () async { + final cubit = + TrustCubit(FakeTrustTransport(me), peerPubkey: peer, selfPubkey: me); + await cubit.load(); + expect(cubit.state.iVouch, isFalse); + + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isTrue); + expect(cubit.state.certifierCount, 1); + + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isFalse); + expect(cubit.state.certifierCount, 0); + await cubit.close(); + }); + + test('never vouches for self', () async { + final cubit = + TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me); + await cubit.load(); + await cubit.toggleVouch(); + expect(cubit.state.iVouch, isFalse); + await cubit.close(); + }); + + test('offline (no transport) never throws', () async { + final cubit = TrustCubit(null, peerPubkey: peer, selfPubkey: me); + expect(cubit.isOnline, isFalse); + await cubit.load(); + await cubit.toggleVouch(); + expect(cubit.state.loading, isFalse); + await cubit.close(); + }); +}