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:
parent
11b242edbf
commit
0e7faaeb90
15 changed files with 560 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
193
apps/app_seeds/lib/ui/report_sheet.dart
Normal file
193
apps/app_seeds/lib/ui/report_sheet.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue