feat(block2): trust UI — vouch for a person in chat (web of trust)
- 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.
This commit is contained in:
parent
4326e79419
commit
7cba4f7fcf
10 changed files with 367 additions and 17 deletions
|
|
@ -366,5 +366,11 @@
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"empty": "No messages yet — say hello",
|
"empty": "No messages yet — say hello",
|
||||||
"offline": "Set up sharing to send messages"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -366,5 +366,11 @@
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"empty": "Aún no hay mensajes — saluda",
|
"empty": "Aún no hay mensajes — saluda",
|
||||||
"offline": "Configura el compartir para enviar mensajes"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -362,5 +362,11 @@
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
"empty": "Ainda não há mensagens — diz olá",
|
"empty": "Ainda não há mensagens — diz olá",
|
||||||
"offline": "Configura a partilha para enviar mensagens"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
late final Translations$unit$en unit = Translations$unit$en.internal(_root);
|
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$market$en market = Translations$market$en.internal(_root);
|
||||||
late final Translations$chat$en chat = Translations$chat$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
|
// Path: app
|
||||||
|
|
@ -1162,6 +1163,27 @@ class Translations$chat$en {
|
||||||
String get offline => 'Set up sharing to send messages';
|
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
|
// Path: intro.slides
|
||||||
class Translations$intro$slides$en {
|
class Translations$intro$slides$en {
|
||||||
Translations$intro$slides$en.internal(this._root);
|
Translations$intro$slides$en.internal(this._root);
|
||||||
|
|
@ -1945,6 +1967,10 @@ extension on Translations {
|
||||||
'chat.send' => 'Send',
|
'chat.send' => 'Send',
|
||||||
'chat.empty' => 'No messages yet — say hello',
|
'chat.empty' => 'No messages yet — say hello',
|
||||||
'chat.offline' => 'Set up sharing to send messages',
|
'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,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
|
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
|
||||||
@override late final _Translations$market$es market = _Translations$market$es._(_root);
|
@override late final _Translations$market$es market = _Translations$market$es._(_root);
|
||||||
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
|
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
|
||||||
|
@override late final _Translations$trust$es trust = _Translations$trust$es._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -646,6 +647,19 @@ class _Translations$chat$es extends Translations$chat$en {
|
||||||
@override String get offline => 'Configura el compartir para enviar mensajes';
|
@override String get offline => '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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
class _Translations$intro$slides$es extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1313,6 +1327,10 @@ extension on TranslationsEs {
|
||||||
'chat.send' => 'Enviar',
|
'chat.send' => 'Enviar',
|
||||||
'chat.empty' => 'Aún no hay mensajes — saluda',
|
'chat.empty' => 'Aún no hay mensajes — saluda',
|
||||||
'chat.offline' => 'Configura el compartir para enviar mensajes',
|
'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,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$unit$pt unit = _Translations$unit$pt._(_root);
|
@override late final _Translations$unit$pt unit = _Translations$unit$pt._(_root);
|
||||||
@override late final _Translations$market$pt market = _Translations$market$pt._(_root);
|
@override late final _Translations$market$pt market = _Translations$market$pt._(_root);
|
||||||
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
|
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
|
||||||
|
@override late final _Translations$trust$pt trust = _Translations$trust$pt._(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: app
|
// Path: app
|
||||||
|
|
@ -642,6 +643,19 @@ class _Translations$chat$pt extends Translations$chat$en {
|
||||||
@override String get offline => 'Configura a partilha para enviar mensagens';
|
@override String get offline => '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
|
// Path: intro.slides
|
||||||
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
|
||||||
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1305,6 +1319,10 @@ extension on TranslationsPt {
|
||||||
'chat.send' => 'Enviar',
|
'chat.send' => 'Enviar',
|
||||||
'chat.empty' => 'Ainda não há mensagens — diz olá',
|
'chat.empty' => 'Ainda não há mensagens — diz olá',
|
||||||
'chat.offline' => 'Configura a partilha para enviar mensagens',
|
'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,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
apps/app_seeds/lib/state/trust_cubit.dart
Normal file
121
apps/app_seeds/lib/state/trust_cubit.dart
Normal file
|
|
@ -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<Object?> get props => [certifierCount, iVouch, 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;
|
||||||
|
|
||||||
|
/// Loads the current certifiers of the peer.
|
||||||
|
Future<void> 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../state/messages_cubit.dart';
|
import '../state/messages_cubit.dart';
|
||||||
|
import '../state/trust_cubit.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
/// 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<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
MessagesCubit? _cubit;
|
SocialSession? _session;
|
||||||
|
MessagesCubit? _messages;
|
||||||
|
TrustCubit? _trust;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
final _input = TextEditingController();
|
final _input = TextEditingController();
|
||||||
|
|
||||||
|
|
@ -38,24 +43,38 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
final cubit = await createMessagesCubit(
|
// One session for this chat carries both messaging and trust (the shared-
|
||||||
widget.social,
|
// connection shape). Offline (no relays / unreachable) → null transports.
|
||||||
widget.settings,
|
final relays = await widget.settings.relayUrls();
|
||||||
peerPubkey: widget.peerPubkey,
|
SocialSession? session;
|
||||||
);
|
if (relays.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
session = await widget.social.openSession(relays);
|
||||||
|
} catch (_) {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await cubit.close();
|
await session?.close();
|
||||||
return;
|
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(() {
|
setState(() {
|
||||||
_cubit = cubit;
|
_session = session;
|
||||||
|
_messages = messages;
|
||||||
|
_trust = trust;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
final cubit = _cubit;
|
final cubit = _messages;
|
||||||
if (cubit == null || _input.text.trim().isEmpty) return;
|
if (cubit == null || _input.text.trim().isEmpty) return;
|
||||||
final text = _input.text;
|
final text = _input.text;
|
||||||
_input.clear();
|
_input.clear();
|
||||||
|
|
@ -64,7 +83,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_cubit?.close();
|
_messages?.close();
|
||||||
|
_trust?.close();
|
||||||
|
_session?.close();
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +93,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final cubit = _cubit;
|
final messages = _messages;
|
||||||
|
final trust = _trust;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(t.chat.title)),
|
appBar: AppBar(title: Text(t.chat.title)),
|
||||||
body: _loading || cubit == null
|
body: _loading || messages == null || trust == null
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: BlocProvider.value(
|
: MultiBlocProvider(
|
||||||
value: cubit,
|
providers: [
|
||||||
|
BlocProvider.value(value: messages),
|
||||||
|
BlocProvider.value(value: trust),
|
||||||
|
],
|
||||||
child: _ChatBody(controller: _input, onSend: _send),
|
child: _ChatBody(controller: _input, onSend: _send),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -109,6 +134,7 @@ class _ChatBody extends StatelessWidget {
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
const _TrustBanner(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: BlocBuilder<MessagesCubit, ChatState>(
|
child: BlocBuilder<MessagesCubit, ChatState>(
|
||||||
builder: (context, state) {
|
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<TrustCubit, TrustState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final cubit = context.read<TrustCubit>();
|
||||||
|
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 {
|
class _Bubble extends StatelessWidget {
|
||||||
const _Bubble({required this.text, required this.mine});
|
const _Bubble({required this.text, required this.mine});
|
||||||
|
|
||||||
|
|
|
||||||
82
apps/app_seeds/test/state/trust_cubit_test.dart
Normal file
82
apps/app_seeds/test/state/trust_cubit_test.dart
Normal file
|
|
@ -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<String, Set<String>> _certs = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<String>> certifiersOf(String subjectPubkey) async =>
|
||||||
|
_certs[subjectPubkey] ?? <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> certify({
|
||||||
|
required String subjectPubkey,
|
||||||
|
Duration validity = const Duration(days: 365),
|
||||||
|
String note = '',
|
||||||
|
}) async =>
|
||||||
|
(_certs[subjectPubkey] ??= {}).add(selfId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revoke({required String subjectPubkey}) async =>
|
||||||
|
_certs[subjectPubkey]?.remove(selfId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Certification>> allCertifications() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue