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.
527 lines
17 KiB
Dart
527 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../di/injector.dart';
|
|
import '../domain/chat_timeline.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/message_store.dart';
|
|
import '../services/profile_cache.dart';
|
|
import '../services/social_connection.dart';
|
|
import '../services/social_service.dart';
|
|
import '../services/unread_service.dart';
|
|
import '../state/messages_cubit.dart';
|
|
import '../state/trust_cubit.dart';
|
|
import 'peer_avatar.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.connection,
|
|
required this.peerPubkey,
|
|
this.messageStore,
|
|
this.profileCache,
|
|
super.key,
|
|
});
|
|
|
|
final SocialService social;
|
|
|
|
/// The shared relay connection (one per identity), carrying messaging + trust.
|
|
final SocialConnection connection;
|
|
final String peerPubkey;
|
|
|
|
/// Optional persistence for chat history (encrypted [ChatDatabase]-backed);
|
|
/// null in tests.
|
|
final MessageStore? messageStore;
|
|
|
|
/// Optional cache of peer display names; null in tests.
|
|
final ProfileCache? profileCache;
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
MessagesCubit? _messages;
|
|
TrustCubit? _trust;
|
|
bool _loading = true;
|
|
String? _peerName;
|
|
String? _peerG1;
|
|
final _input = TextEditingController();
|
|
|
|
/// App-wide unread tracker (absent in tests / inventory-only builds). While
|
|
/// this chat is open it is the "active peer", so incoming messages for it are
|
|
/// read on arrival and never badge or notify.
|
|
UnreadService? _unread;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_unread = getIt.isRegistered<UnreadService>()
|
|
? getIt<UnreadService>()
|
|
: null;
|
|
// Set synchronously (before any await) so a message landing during _init is
|
|
// already suppressed.
|
|
_unread?.activePeer = widget.peerPubkey;
|
|
unawaited(_unread?.markRead(widget.peerPubkey));
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
// The shared connection carries both messaging and trust. Offline (not
|
|
// connected / unreachable) → null transports and the screen degrades.
|
|
final session = await widget.connection.session();
|
|
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
|
|
if (!mounted) return; // shared session is owned by the connection, not us
|
|
final self = widget.social.publicKeyHex;
|
|
final messages = MessagesCubit(
|
|
session?.messages,
|
|
peerPubkey: widget.peerPubkey,
|
|
selfPubkey: self,
|
|
store: widget.messageStore,
|
|
)..start();
|
|
final trust = TrustCubit(
|
|
session?.trust,
|
|
peerPubkey: widget.peerPubkey,
|
|
selfPubkey: self,
|
|
);
|
|
unawaited(trust.load());
|
|
setState(() {
|
|
_messages = messages;
|
|
_trust = trust;
|
|
_peerName = cachedName;
|
|
_loading = false;
|
|
});
|
|
|
|
// Freshen the peer's name from their published profile.
|
|
final live = session;
|
|
if (live != null && widget.profileCache != null) {
|
|
unawaited(() async {
|
|
try {
|
|
final profile = await live.profile.fetch(widget.peerPubkey);
|
|
if (profile != null && mounted) {
|
|
if (profile.name.isNotEmpty) {
|
|
await widget.profileCache!.setName(
|
|
widget.peerPubkey,
|
|
profile.name,
|
|
);
|
|
}
|
|
setState(() {
|
|
if (profile.name.isNotEmpty) _peerName = profile.name;
|
|
if (profile.g1.isNotEmpty) _peerG1 = profile.g1;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
// best effort
|
|
}
|
|
}());
|
|
}
|
|
}
|
|
|
|
/// Opens the peer's Ğ1 wallet so you can pay them (Ğ1 is separate from the
|
|
/// Nostr key — this uses the address they published in their profile). Falls
|
|
/// back to copying the address if no wallet handles the link.
|
|
///
|
|
/// NOTE: confirm the deep-link scheme for the target wallet (Ğ1nkgo / Ğecko /
|
|
/// Cesium) — this uses the Cesium web wallet as a universal fallback.
|
|
Future<void> _payG1() async {
|
|
final g1 = _peerG1;
|
|
if (g1 == null) return;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final t = context.t;
|
|
final uri = Uri.parse('https://demo.cesium.app/#/app/wallet/$g1/');
|
|
var launched = false;
|
|
try {
|
|
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
} catch (_) {
|
|
launched = false;
|
|
}
|
|
if (!launched && mounted) {
|
|
await Clipboard.setData(ClipboardData(text: g1));
|
|
messenger.showSnackBar(SnackBar(content: Text(t.chat.g1Copied)));
|
|
}
|
|
}
|
|
|
|
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() {
|
|
if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null;
|
|
// Mark read once more on the way out, so anything that arrived while the
|
|
// chat was open (and was auto-read) stays cleared.
|
|
unawaited(_unread?.markRead(widget.peerPubkey));
|
|
_messages?.close();
|
|
_trust?.close();
|
|
// The session belongs to the shared connection — don't close it here.
|
|
_input.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final messages = _messages;
|
|
final trust = _trust;
|
|
final t = context.t;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_peerName ?? shortPubkey(widget.peerPubkey)),
|
|
actions: [
|
|
if (_peerG1 != null)
|
|
IconButton(
|
|
key: const Key('chat.payG1'),
|
|
icon: const Icon(Icons.payments_outlined),
|
|
tooltip: t.chat.payG1,
|
|
onPressed: _payG1,
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
peerName: _peerName,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatBody extends StatelessWidget {
|
|
const _ChatBody({
|
|
required this.controller,
|
|
required this.onSend,
|
|
this.peerName,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final Future<void> Function() onSend;
|
|
final String? peerName;
|
|
|
|
@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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
// Surface a failed send instead of losing it silently (relay down, etc.).
|
|
return BlocListener<MessagesCubit, ChatState>(
|
|
listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error,
|
|
listener: (context, state) {
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(content: Text(t.chat.sendError)));
|
|
},
|
|
child: Column(
|
|
children: [
|
|
const _TrustBanner(),
|
|
Expanded(child: ChatMessageList(peerName: peerName)),
|
|
_Composer(controller: controller, onSend: onSend),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The scrolling list of chat bubbles. Anchored at the bottom (chat
|
|
/// convention): the newest message is always in view, and new arrivals show up
|
|
/// in place instead of landing below the fold. Reads the [MessagesCubit] from
|
|
/// context.
|
|
class ChatMessageList extends StatelessWidget {
|
|
const ChatMessageList({this.peerName, super.key});
|
|
|
|
/// The peer's display name, for their avatar's initial (null → a glyph).
|
|
final String? peerName;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final cubit = context.read<MessagesCubit>();
|
|
return BlocBuilder<MessagesCubit, ChatState>(
|
|
builder: (context, state) {
|
|
if (state.messages.isEmpty) {
|
|
return Center(
|
|
child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)),
|
|
);
|
|
}
|
|
// Group into bubbles + day separators (oldest-first), then render
|
|
// bottom-anchored: with `reverse: true` index 0 is the bottom-most
|
|
// (newest), so map it to the tail. Scrolling up to read history stays
|
|
// put when new messages come in.
|
|
final items = chatTimeline(state.messages);
|
|
return ListView.builder(
|
|
reverse: true,
|
|
padding: const EdgeInsets.all(12),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, i) {
|
|
final item = items[items.length - 1 - i];
|
|
return switch (item) {
|
|
ChatDaySeparator(:final day) => _DaySeparator(day: day),
|
|
ChatMessageRow(:final message) => _MessageRow(
|
|
message: message,
|
|
mine: cubit.isMine(message),
|
|
peerName: peerName,
|
|
),
|
|
};
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
// Strongest applicable signal decides the badge (circle > vouched >
|
|
// unknown).
|
|
final (IconData icon, String label, bool strong) = switch (state.tier) {
|
|
TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true),
|
|
TrustTier.vouched => (
|
|
Icons.people_outline,
|
|
t.trust.count(n: state.certifierCount),
|
|
false,
|
|
),
|
|
TrustTier.unknown => (Icons.person_outline, t.trust.none, false),
|
|
};
|
|
return Container(
|
|
width: double.infinity,
|
|
color: seedPrimaryContainer.withValues(alpha: 0.5),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 18, color: strong ? seedGreen : seedMuted),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: strong ? seedGreen : seedOnSurface,
|
|
fontSize: 13,
|
|
fontWeight: strong ? 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A centered pill introducing a new day ("Today", "Yesterday", or a
|
|
/// locale-formatted date). Localized via [chatDayLabel].
|
|
class _DaySeparator extends StatelessWidget {
|
|
const _DaySeparator({required this.day});
|
|
|
|
final DateTime day;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
// Localizations locale, not the raw app locale: `ast` has no intl date
|
|
// symbols and is mapped to Spanish for the framework (see app.dart).
|
|
final localeCode = Localizations.localeOf(context).languageCode;
|
|
final label = chatDayLabel(
|
|
day,
|
|
now: DateTime.now(),
|
|
today: t.chat.today,
|
|
yesterday: t.chat.yesterday,
|
|
localeCode: localeCode,
|
|
);
|
|
return Center(
|
|
key: const Key('chat.daySeparator'),
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: seedMuted.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: seedMuted,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One message line: my messages align to the trailing edge with no avatar;
|
|
/// the peer's align to the leading edge with their avatar (1:1 convention).
|
|
class _MessageRow extends StatelessWidget {
|
|
const _MessageRow({required this.message, required this.mine, this.peerName});
|
|
|
|
final PrivateMessage message;
|
|
final bool mine;
|
|
final String? peerName;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (mine) {
|
|
return Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: _Bubble(message: message, mine: true),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
PeerAvatar(pubkey: message.fromPubkey, name: peerName),
|
|
const SizedBox(width: 8),
|
|
Flexible(child: _Bubble(message: message, mine: false)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Bubble extends StatelessWidget {
|
|
const _Bubble({required this.message, required this.mine});
|
|
|
|
final PrivateMessage message;
|
|
final bool mine;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final localeCode = Localizations.localeOf(context).languageCode;
|
|
final time = chatBubbleTime(message.at, localeCode);
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: mine ? seedGreen : seedPrimaryContainer,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Long text can be selected/copied — useful for addresses, links.
|
|
SelectableText(
|
|
message.text,
|
|
style: TextStyle(
|
|
color: mine ? Colors.white : seedOnSurface,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
// A subtle timestamp, trailing edge (RTL-aware).
|
|
Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: Text(
|
|
time,
|
|
style: TextStyle(
|
|
color: mine ? Colors.white70 : seedMuted,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|