diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index f96a284..f8f41ad 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index cb8043c..a33d644 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 34db278..a7db478 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -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" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index f904738..8684e22 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: 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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index e3324e2..a80492d 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 2e1ecfa..95e01a6 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 7f2b7d6..4e7e7f8 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -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, }; } diff --git a/apps/app_seeds/lib/state/trust_cubit.dart b/apps/app_seeds/lib/state/trust_cubit.dart index 998deb9..b4670b8 100644 --- a/apps/app_seeds/lib/state/trust_cubit.dart +++ b/apps/app_seeds/lib/state/trust_cubit.dart @@ -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 get props => [certifierCount, iVouch, loading, busy]; + List 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 { 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 load() async { final transport = _transport; if (transport == null) { @@ -65,10 +84,20 @@ class TrustCubit extends Cubit { 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, )); } diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index cc29932..962b825 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -173,20 +173,31 @@ class _TrustBanner extends StatelessWidget { builder: (context, state) { final cubit = context.read(); 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( diff --git a/apps/app_seeds/test/state/trust_cubit_test.dart b/apps/app_seeds/test/state/trust_cubit_test.dart index bf87e7c..51d0672 100644 --- a/apps/app_seeds/test/state/trust_cubit_test.dart +++ b/apps/app_seeds/test/state/trust_cubit_test.dart @@ -6,7 +6,11 @@ import 'package:tane/state/trust_cubit.dart'; class FakeTrustTransport implements TrustTransport { FakeTrustTransport(this.selfId); final String selfId; - final Map> _certs = {}; + final Map> _certs = {}; // subject -> issuers + + /// Test helper: record an arbitrary issuer→subject vouch (to build a graph). + void addCert(String issuer, String subject) => + (_certs[subject] ??= {}).add(issuer); @override Future> certifiersOf(String subjectPubkey) async => @@ -18,14 +22,22 @@ class FakeTrustTransport implements TrustTransport { Duration validity = const Duration(days: 365), String note = '', }) async => - (_certs[subjectPubkey] ??= {}).add(selfId); + addCert(selfId, subjectPubkey); @override Future revoke({required String subjectPubkey}) async => _certs[subjectPubkey]?.remove(selfId); @override - Future> allCertifications() async => const []; + Future> allCertifications() async => [ + for (final entry in _certs.entries) + for (final issuer in entry.value) + Certification( + issuer: issuer, + subject: entry.key, + issuedAt: DateTime(2026), + ), + ]; @override Future close() async {} @@ -62,6 +74,23 @@ void main() { await cubit.close(); }); + test('a friend-of-a-friend is in your circle; a stranger is not', () async { + final transport = FakeTrustTransport(me) + ..addCert(me, 'friend') // you vouch for a friend + ..addCert('friend', peer) // your friend vouches for the peer + ..addCert('stranger', 'other'); // unrelated to you + + final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me); + await inCircle.load(); + expect(inCircle.state.knownToYou, isTrue); + await inCircle.close(); + + final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me); + await outside.load(); + expect(outside.state.knownToYou, isFalse); + await outside.close(); + }); + test('never vouches for self', () async { final cubit = TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me);