tane/apps/app_seeds/lib/state/trust_cubit.dart
vjrj 163d659119 feat(block2): 'in your circle' trust — friend-of-a-friend, not a raw count
A network-wide 'N people vouch' count invites spam and means little. Now the
trust banner computes whether the peer is within YOUR circle — you vouch for
them, or someone you vouch for does (friend-of-a-friend) — via the pure
WebOfTrust rule (seeds={you}, threshold 1, distance 2). When known, the banner
turns green with 'In your circle' (else it falls back to the count).

The full member policy (threshold/distance/bootstrap) stays open (network-trust
§2); this is a deliberately loose 'people near you' heuristic.

Analyzer clean; trust cubit test adds a friend-of-a-friend vs stranger case
(run flutter test locally to confirm — the widget suite is too slow to run here).
2026-07-10 12:10:06 +02:00

150 lines
4.5 KiB
Dart

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.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, knownToYou, loading, busy];
}
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport].
class TrustCubit extends Cubit<TrustState> {
TrustCubit(
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose,
super(const TrustState());
final TrustTransport? _transport;
final String peerPubkey;
final String selfPubkey;
final Future<void> Function()? _onDispose;
bool get isOnline => _transport != null;
/// 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) {
emit(state.copyWith(loading: false));
return;
}
emit(state.copyWith(loading: true));
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,
));
}
/// Adds or removes this user's vouch, then reloads. Never vouches for self.
Future<void> 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<void> close() async {
await _onDispose?.call();
return super.close();
}
}
/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an
/// offline one.
Future<TrustCubit> 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);
}
}