feat(social): standard NIP-56 reporting — ReportTransport in commons_core, report sheet on offers and chats, local hide + optional block

This commit is contained in:
vjrj 2026-07-13 07:56:43 +02:00
parent 11b242edbf
commit 0e7faaeb90
15 changed files with 560 additions and 15 deletions

View file

@ -24,6 +24,7 @@ import '../state/trust_cubit.dart';
import 'market_gate.dart';
import 'peer_avatar.dart';
import 'rating_sheet.dart';
import 'report_sheet.dart';
import 'theme.dart';
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
@ -202,6 +203,20 @@ class _ChatScreenState extends State<ChatScreen> {
widget.settings ??
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
/// Reports this peer (a standard report event to the community servers);
/// when they were also blocked from the sheet, leaves the chat too.
Future<void> _reportPeer() async {
final outcome = await showReportSheet(
context,
connection: widget.connection,
subjectPubkey: widget.peerPubkey,
settings: _settings,
);
if (outcome != null && outcome.blocked && mounted) {
Navigator.of(context).pop();
}
}
/// Blocks this peer after confirmation and leaves the chat; their offers,
/// conversation and future messages disappear (all locally).
Future<void> _blockPeer() async {
@ -287,19 +302,24 @@ class _ChatScreenState extends State<ChatScreen> {
tooltip: t.chat.payG1,
onPressed: _payG1,
),
if (_settings != null)
PopupMenuButton<String>(
key: const Key('chat.menu'),
onSelected: (value) {
if (value == 'block') _blockPeer();
},
itemBuilder: (context) => [
PopupMenuButton<String>(
key: const Key('chat.menu'),
onSelected: (value) {
if (value == 'report') _reportPeer();
if (value == 'block') _blockPeer();
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'report',
child: Text(t.report.person),
),
if (_settings != null)
PopupMenuItem(
value: 'block',
child: Text(t.block.action),
),
],
),
],
),
],
),
body: _loading || messages == null || trust == null || rating == null

View file

@ -16,6 +16,7 @@ import '../state/peer_rating_cubit.dart';
import 'market_widgets.dart';
import 'peer_avatar.dart';
import 'rating_sheet.dart';
import 'report_sheet.dart';
import 'theme.dart';
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
@ -172,6 +173,26 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
widget.settings ??
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
/// Reports this offer (a standard report event to the community servers),
/// hides it locally, and goes back to the market.
Future<void> _reportOffer() async {
final offer = widget.offer;
final outcome = await showReportSheet(
context,
connection: widget.connection,
subjectPubkey: offer.authorPubkeyHex,
offerAddress:
'${Nip99Codec.kindActive}:${offer.authorPubkeyHex}:${offer.id}',
settings: _settings,
);
if (outcome == null || !outcome.sent || !mounted) return;
await _settings?.hideOffer(
authorPubkeyHex: offer.authorPubkeyHex,
offerId: offer.id,
);
if (mounted && context.canPop()) context.pop();
}
/// Blocks the offer's author after confirmation and goes back to the market
/// (which reloads the blocklist and drops their offers).
Future<void> _blockAuthor() async {
@ -231,17 +252,23 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
tooltip: saved ? t.favorites.remove : t.favorites.save,
onPressed: _toggleSaved,
),
if (!widget.mine && _settings != null)
if (!widget.mine)
PopupMenuButton<String>(
key: const Key('offer.menu'),
onSelected: (value) {
if (value == 'report') _reportOffer();
if (value == 'block') _blockAuthor();
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'block',
child: Text(t.block.action),
value: 'report',
child: Text(t.report.offer),
),
if (_settings != null)
PopupMenuItem(
value: 'block',
child: Text(t.block.action),
),
],
),
],

View file

@ -94,6 +94,7 @@ class _MarketScreenState extends State<MarketScreen> {
final cubit =
await createOffersCubit(widget.connection, repository: repo);
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
final area = await widget.settings.areaGeohash();
if (!mounted) {
await cubit.close();
@ -125,10 +126,13 @@ class _MarketScreenState extends State<MarketScreen> {
}
}
/// Re-reads the blocklist (the offer detail can block an author) so their
/// offers drop out of the visible list at once.
/// Re-reads the blocklist and hidden (reported) offers the offer detail
/// can change both so they drop out of the visible list at once.
Future<void> _reloadBlocked() async {
_cubit?.setBlockedAuthors(await widget.settings.blockedPubkeys());
final cubit = _cubit;
if (cubit == null) return;
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
}
Future<void> _openConfig() async {

View file

@ -0,0 +1,193 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import '../services/social_connection.dart';
import '../services/social_settings.dart';
import 'theme.dart';
/// What a submitted report did, so the caller can react (hide the offer,
/// leave the chat when the person was also blocked).
typedef ReportOutcome = ({bool sent, bool blocked});
/// Opens the report sheet for a person (and optionally one of their offers,
/// via [offerAddress] `kind:pubkey:d`). Publishes a standard report to the
/// user's community servers and, when ticked, also blocks the person locally.
/// Returns null when dismissed without sending.
Future<ReportOutcome?> showReportSheet(
BuildContext context, {
required SocialConnection connection,
required String subjectPubkey,
String? offerAddress,
SocialSettings? settings,
}) {
return showModalBottomSheet<ReportOutcome>(
context: context,
isScrollControlled: true,
builder: (_) => ReportSheet(
connection: connection,
subjectPubkey: subjectPubkey,
offerAddress: offerAddress,
settings: settings,
),
);
}
/// The report form: what's wrong (mapped onto the standard report categories),
/// optional details, and an optional "also block" tick.
class ReportSheet extends StatefulWidget {
const ReportSheet({
required this.connection,
required this.subjectPubkey,
this.offerAddress,
this.settings,
super.key,
});
final SocialConnection connection;
final String subjectPubkey;
/// When reporting one offer (not just the person): its address.
final String? offerAddress;
/// Enables the "also block this person" option; null hides it.
final SocialSettings? settings;
@override
State<ReportSheet> createState() => _ReportSheetState();
}
class _ReportSheetState extends State<ReportSheet> {
ReportReason _reason = ReportReason.spam;
bool _alsoBlock = false;
bool _sending = false;
final _details = TextEditingController();
@override
void dispose() {
_details.dispose();
super.dispose();
}
Future<void> _submit() async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
setState(() => _sending = true);
try {
final session = await widget.connection.session();
if (session == null) throw StateError('offline');
await session.reports.report(
subjectPubkey: widget.subjectPubkey,
eventAddress: widget.offerAddress,
reason: _reason,
details: _details.text.trim(),
);
var blocked = false;
if (_alsoBlock && widget.settings != null) {
await widget.settings!.block(widget.subjectPubkey);
blocked = true;
}
if (!mounted) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.report.sentHidden)));
Navigator.of(context).pop((sent: true, blocked: blocked));
} catch (_) {
if (!mounted) return;
setState(() => _sending = false);
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.report.failed)));
}
}
@override
Widget build(BuildContext context) {
final t = context.t;
final reasons = <(ReportReason, String)>[
(ReportReason.spam, t.report.reasonSpam),
(ReportReason.profanity, t.report.reasonAbuse),
(ReportReason.illegal, t.report.reasonIllegal),
(ReportReason.other, t.report.reasonOther),
];
return SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.report.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: seedOnSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
t.report.prompt,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
const SizedBox(height: 4),
RadioGroup<ReportReason>(
groupValue: _reason,
onChanged: _sending
? (_) {}
: (v) => setState(() => _reason = v ?? _reason),
child: Column(
children: [
for (final (value, label) in reasons)
RadioListTile<ReportReason>(
value: value,
title: Text(label),
dense: true,
contentPadding: EdgeInsets.zero,
),
],
),
),
TextField(
controller: _details,
enabled: !_sending,
minLines: 1,
maxLines: 3,
decoration: InputDecoration(hintText: t.report.detailsHint),
),
if (widget.settings != null)
CheckboxListTile(
value: _alsoBlock,
onChanged: _sending
? null
: (v) => setState(() => _alsoBlock = v ?? false),
title: Text(t.report.alsoBlock),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton(
key: const Key('report.send'),
onPressed: _sending ? null : _submit,
child: _sending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(t.report.send),
),
),
],
),
),
);
}
}