The global membership rule (curated bootstrap referents + sigQty/stepMax parameters) solved sybil-proof identity for a UBI — a problem this app doesn't have — and its screen leaked graph jargon (npub roots, parameter steppers). Trust is now computed from the user's own position only: you vouch / vouched by people you know (distance <=2) / vouched by N. - TrustCubit: drop networkMember tier, referents and params; keep the circle rule and the 365-day vouch expiry (renewable, self-pruning). - Delete TrustReferents, WotSettings, TrustNetworkScreen and the bundled referents asset; unwire injector/bootstrap/app/chat. - New 'Your people' screen (/your-people, from the profile): who you vouch for (revocable) and who vouches for you, names via ProfileCache. - i18n: wot.* removed, yourPeople.* added (en/es/pt/ast); trust.member removed. Kind 30777 events on relays stay fully compatible — only the interpretation changes.
240 lines
7 KiB
Dart
240 lines
7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/profile_cache.dart';
|
|
import '../services/social_connection.dart';
|
|
import '../services/social_service.dart';
|
|
import 'theme.dart';
|
|
|
|
/// "Your people": who you vouch for and who vouches for you, in human
|
|
/// language. Ego-centric by design — no roots, no parameters, no graph talk.
|
|
/// Reached from the profile; rows open the 1:1 chat.
|
|
class YourPeopleScreen extends StatefulWidget {
|
|
const YourPeopleScreen({
|
|
required this.social,
|
|
required this.connection,
|
|
this.profileCache,
|
|
super.key,
|
|
});
|
|
|
|
final SocialService social;
|
|
|
|
/// The shared relay connection (one per identity), carrying trust.
|
|
final SocialConnection connection;
|
|
|
|
/// Optional cache of peer display names; null in tests.
|
|
final ProfileCache? profileCache;
|
|
|
|
@override
|
|
State<YourPeopleScreen> createState() => _YourPeopleScreenState();
|
|
}
|
|
|
|
class _YourPeopleScreenState extends State<YourPeopleScreen> {
|
|
TrustTransport? _transport;
|
|
bool _loading = true;
|
|
bool _busy = false;
|
|
List<String> _youVouchFor = const [];
|
|
List<String> _vouchForYou = const [];
|
|
final Map<String, String> _names = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
final session = await widget.connection.session();
|
|
if (!mounted) return; // shared session is owned by the connection, not us
|
|
_transport = session?.trust;
|
|
await _load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
if (mounted) setState(() => _loading = false);
|
|
return;
|
|
}
|
|
final self = widget.social.publicKeyHex;
|
|
final now = DateTime.now();
|
|
final certs = (await transport.allCertifications())
|
|
.where((c) => c.isValidAt(now))
|
|
.toList();
|
|
final given = [
|
|
for (final c in certs)
|
|
if (c.issuer == self) c.subject,
|
|
]..sort();
|
|
final received = [
|
|
for (final c in certs)
|
|
if (c.subject == self) c.issuer,
|
|
]..sort();
|
|
final cache = widget.profileCache;
|
|
if (cache != null) {
|
|
for (final peer in {...given, ...received}) {
|
|
final name = await cache.name(peer);
|
|
if (name != null) _names[peer] = name;
|
|
}
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_youVouchFor = given;
|
|
_vouchForYou = received;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _revoke(String peer) async {
|
|
final t = context.t;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(t.yourPeople.revokeConfirm),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(t.yourPeople.revoke),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final transport = _transport;
|
|
if (confirmed != true || transport == null || !mounted) return;
|
|
setState(() => _busy = true);
|
|
await transport.revoke(subjectPubkey: peer);
|
|
await _load();
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t.yourPeople.title)),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _transport == null
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
t.yourPeople.offline,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: seedMuted, fontSize: 15),
|
|
),
|
|
),
|
|
)
|
|
: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(
|
|
16, 8, 16, 8),
|
|
child: Text(
|
|
t.yourPeople.help,
|
|
style:
|
|
const TextStyle(color: seedMuted, fontSize: 14),
|
|
),
|
|
),
|
|
_SectionHeader(label: t.yourPeople.youVouchFor),
|
|
if (_youVouchFor.isEmpty)
|
|
_EmptyNote(text: t.yourPeople.youVouchForEmpty)
|
|
else
|
|
for (final peer in _youVouchFor)
|
|
_PersonTile(
|
|
key: Key('yourPeople.given.$peer'),
|
|
name: _names[peer] ?? shortPubkey(peer),
|
|
onOpen: () => context.push('/chat/$peer'),
|
|
trailing: TextButton(
|
|
onPressed: _busy ? null : () => _revoke(peer),
|
|
child: Text(t.yourPeople.revoke),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_SectionHeader(label: t.yourPeople.vouchesForYou),
|
|
if (_vouchForYou.isEmpty)
|
|
_EmptyNote(text: t.yourPeople.vouchesForYouEmpty)
|
|
else
|
|
for (final peer in _vouchForYou)
|
|
_PersonTile(
|
|
key: Key('yourPeople.received.$peer'),
|
|
name: _names[peer] ?? shortPubkey(peer),
|
|
onOpen: () => context.push('/chat/$peer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 16, 4),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: seedGreen,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyNote extends StatelessWidget {
|
|
const _EmptyNote({required this.text});
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 4),
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(color: seedMuted, fontSize: 14),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonTile extends StatelessWidget {
|
|
const _PersonTile({
|
|
required this.name,
|
|
required this.onOpen,
|
|
this.trailing,
|
|
super.key,
|
|
});
|
|
|
|
final String name;
|
|
final VoidCallback onOpen;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: const CircleAvatar(
|
|
backgroundColor: seedAvatar,
|
|
child: Icon(Icons.person, color: seedOnAvatar),
|
|
),
|
|
title: Text(name),
|
|
trailing: trailing,
|
|
onTap: onOpen,
|
|
);
|
|
}
|
|
}
|