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).
294 lines
8.5 KiB
Dart
294 lines
8.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
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
|
|
/// metadata-hiding (NIP-17) under the hood; local-first, so it degrades to a
|
|
/// "set up sharing" note when there's no relay.
|
|
class ChatScreen extends StatefulWidget {
|
|
const ChatScreen({
|
|
required this.social,
|
|
required this.settings,
|
|
required this.peerPubkey,
|
|
super.key,
|
|
});
|
|
|
|
final SocialService social;
|
|
final SocialSettings settings;
|
|
final String peerPubkey;
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
SocialSession? _session;
|
|
MessagesCubit? _messages;
|
|
TrustCubit? _trust;
|
|
bool _loading = true;
|
|
final _input = TextEditingController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
// 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 session?.close();
|
|
return;
|
|
}
|
|
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(() {
|
|
_session = session;
|
|
_messages = messages;
|
|
_trust = trust;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final cubit = _messages;
|
|
if (cubit == null || _input.text.trim().isEmpty) return;
|
|
final text = _input.text;
|
|
_input.clear();
|
|
await cubit.send(text);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_messages?.close();
|
|
_trust?.close();
|
|
_session?.close();
|
|
_input.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final messages = _messages;
|
|
final trust = _trust;
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t.chat.title)),
|
|
body: _loading || messages == null || trust == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider.value(value: messages),
|
|
BlocProvider.value(value: trust),
|
|
],
|
|
child: _ChatBody(controller: _input, onSend: _send),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatBody extends StatelessWidget {
|
|
const _ChatBody({required this.controller, required this.onSend});
|
|
|
|
final TextEditingController controller;
|
|
final Future<void> Function() onSend;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final cubit = context.read<MessagesCubit>();
|
|
if (!cubit.isOnline) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Text(
|
|
t.chat.offline,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: seedMuted, fontSize: 15),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
const _TrustBanner(),
|
|
Expanded(
|
|
child: BlocBuilder<MessagesCubit, ChatState>(
|
|
builder: (context, state) {
|
|
if (state.messages.isEmpty) {
|
|
return Center(
|
|
child: Text(t.chat.empty,
|
|
style: const TextStyle(color: seedMuted)),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.all(12),
|
|
itemCount: state.messages.length,
|
|
itemBuilder: (context, i) {
|
|
final m = state.messages[i];
|
|
return _Bubble(text: m.text, mine: cubit.isMine(m));
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
_Composer(controller: controller, onSend: onSend),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
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: [
|
|
Icon(
|
|
known ? Icons.verified : Icons.verified_user_outlined,
|
|
size: 18,
|
|
color: seedGreen,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
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(
|
|
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});
|
|
|
|
final String text;
|
|
final bool mine;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: mine ? seedGreen : seedPrimaryContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
color: mine ? Colors.white : seedOnSurface,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Composer extends StatelessWidget {
|
|
const _Composer({required this.controller, required this.onSend});
|
|
|
|
final TextEditingController controller;
|
|
final Future<void> Function() onSend;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
key: const Key('chat.input'),
|
|
controller: controller,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => onSend(),
|
|
decoration: InputDecoration(
|
|
hintText: t.chat.hint,
|
|
filled: true,
|
|
fillColor: seedField,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filled(
|
|
key: const Key('chat.send'),
|
|
icon: const Icon(Icons.send),
|
|
tooltip: t.chat.send,
|
|
onPressed: onSend,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|