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
|
|
@ -97,6 +97,7 @@ class SocialSession {
|
||||||
messages = NostrMessageTransport(_channel),
|
messages = NostrMessageTransport(_channel),
|
||||||
trust = NostrTrustTransport(_channel),
|
trust = NostrTrustTransport(_channel),
|
||||||
ratings = NostrRatingTransport(_channel),
|
ratings = NostrRatingTransport(_channel),
|
||||||
|
reports = NostrReportTransport(_channel),
|
||||||
profile = NostrProfileTransport(_channel),
|
profile = NostrProfileTransport(_channel),
|
||||||
sync = NostrSyncTransport(
|
sync = NostrSyncTransport(
|
||||||
_channel,
|
_channel,
|
||||||
|
|
@ -110,6 +111,7 @@ class SocialSession {
|
||||||
final MessageTransport messages;
|
final MessageTransport messages;
|
||||||
final TrustTransport trust;
|
final TrustTransport trust;
|
||||||
final RatingTransport ratings;
|
final RatingTransport ratings;
|
||||||
|
final ReportTransport reports;
|
||||||
final ProfileTransport profile;
|
final ProfileTransport profile;
|
||||||
|
|
||||||
/// Device-to-device inventory sync for THIS identity's own devices.
|
/// Device-to-device inventory sync for THIS identity's own devices.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class SocialSettings {
|
||||||
static const _relaysKey = 'tane.social.relays';
|
static const _relaysKey = 'tane.social.relays';
|
||||||
static const _searchPrecisionKey = 'tane.social.search_precision';
|
static const _searchPrecisionKey = 'tane.social.search_precision';
|
||||||
static const _blockedKey = 'tane.social.blocked_pubkeys';
|
static const _blockedKey = 'tane.social.blocked_pubkeys';
|
||||||
|
static const _hiddenOffersKey = 'tane.social.hidden_offers';
|
||||||
|
|
||||||
/// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km
|
/// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km
|
||||||
/// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region").
|
/// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region").
|
||||||
|
|
@ -109,4 +110,25 @@ class SocialSettings {
|
||||||
..remove(pubkeyHex.trim().toLowerCase());
|
..remove(pubkeyHex.trim().toLowerCase());
|
||||||
await _store.write(_blockedKey, set.join('\n'));
|
await _store.write(_blockedKey, set.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stable key of one published offer, for the hidden-offers set.
|
||||||
|
static String offerKey(String authorPubkeyHex, String offerId) =>
|
||||||
|
'${authorPubkeyHex.trim().toLowerCase()}:$offerId';
|
||||||
|
|
||||||
|
/// Offers this user reported and no longer wants to see, as
|
||||||
|
/// [offerKey] entries. Purely local, like the blocklist.
|
||||||
|
Future<Set<String>> hiddenOfferKeys() async {
|
||||||
|
final raw = await _store.read(_hiddenOffersKey);
|
||||||
|
if (raw == null) return <String>{};
|
||||||
|
return raw.split('\n').where((s) => s.trim().isNotEmpty).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> hideOffer({
|
||||||
|
required String authorPubkeyHex,
|
||||||
|
required String offerId,
|
||||||
|
}) async {
|
||||||
|
final set = await hiddenOfferKeys()
|
||||||
|
..add(offerKey(authorPubkeyHex, offerId));
|
||||||
|
await _store.write(_hiddenOffersKey, set.join('\n'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class OffersState extends Equatable {
|
||||||
this.categoryFilter = const {},
|
this.categoryFilter = const {},
|
||||||
this.organicOnly = false,
|
this.organicOnly = false,
|
||||||
this.blockedAuthors = const {},
|
this.blockedAuthors = const {},
|
||||||
|
this.hiddenOfferKeys = const {},
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = false,
|
this.hasSearched = false,
|
||||||
|
|
@ -51,6 +52,10 @@ class OffersState extends Equatable {
|
||||||
/// already discovered.
|
/// already discovered.
|
||||||
final Set<String> blockedAuthors;
|
final Set<String> blockedAuthors;
|
||||||
|
|
||||||
|
/// Individual offers this user reported and hid ("author:id" keys, see
|
||||||
|
/// SocialSettings.offerKey). Same idea as [blockedAuthors], finer grain.
|
||||||
|
final Set<String> hiddenOfferKeys;
|
||||||
|
|
||||||
final bool searching;
|
final bool searching;
|
||||||
final bool publishing;
|
final bool publishing;
|
||||||
|
|
||||||
|
|
@ -67,6 +72,9 @@ class OffersState extends Equatable {
|
||||||
final q = query.trim().toLowerCase();
|
final q = query.trim().toLowerCase();
|
||||||
return offers.where((o) {
|
return offers.where((o) {
|
||||||
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
||||||
|
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
||||||
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
||||||
if (categoryFilter.isNotEmpty &&
|
if (categoryFilter.isNotEmpty &&
|
||||||
|
|
@ -104,6 +112,7 @@ class OffersState extends Equatable {
|
||||||
Set<String>? categoryFilter,
|
Set<String>? categoryFilter,
|
||||||
bool? organicOnly,
|
bool? organicOnly,
|
||||||
Set<String>? blockedAuthors,
|
Set<String>? blockedAuthors,
|
||||||
|
Set<String>? hiddenOfferKeys,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
|
|
@ -117,6 +126,7 @@ class OffersState extends Equatable {
|
||||||
categoryFilter: categoryFilter ?? this.categoryFilter,
|
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||||
organicOnly: organicOnly ?? this.organicOnly,
|
organicOnly: organicOnly ?? this.organicOnly,
|
||||||
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
||||||
|
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
|
|
@ -133,6 +143,7 @@ class OffersState extends Equatable {
|
||||||
categoryFilter,
|
categoryFilter,
|
||||||
organicOnly,
|
organicOnly,
|
||||||
blockedAuthors,
|
blockedAuthors,
|
||||||
|
hiddenOfferKeys,
|
||||||
searching,
|
searching,
|
||||||
publishing,
|
publishing,
|
||||||
hasSearched,
|
hasSearched,
|
||||||
|
|
@ -191,6 +202,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
categoryFilter: state.categoryFilter,
|
categoryFilter: state.categoryFilter,
|
||||||
organicOnly: state.organicOnly,
|
organicOnly: state.organicOnly,
|
||||||
blockedAuthors: state.blockedAuthors,
|
blockedAuthors: state.blockedAuthors,
|
||||||
|
hiddenOfferKeys: state.hiddenOfferKeys,
|
||||||
searching: true,
|
searching: true,
|
||||||
hasSearched: true,
|
hasSearched: true,
|
||||||
));
|
));
|
||||||
|
|
@ -250,6 +262,10 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
void setBlockedAuthors(Set<String> pubkeys) =>
|
void setBlockedAuthors(Set<String> pubkeys) =>
|
||||||
emit(state.copyWith(blockedAuthors: pubkeys));
|
emit(state.copyWith(blockedAuthors: pubkeys));
|
||||||
|
|
||||||
|
/// Replaces the set of locally hidden (reported) offers.
|
||||||
|
void setHiddenOffers(Set<String> offerKeys) =>
|
||||||
|
emit(state.copyWith(hiddenOfferKeys: offerKeys));
|
||||||
|
|
||||||
/// Clears every chip filter (leaves the text search untouched).
|
/// Clears every chip filter (leaves the text search untouched).
|
||||||
void clearFilters() => emit(state.copyWith(
|
void clearFilters() => emit(state.copyWith(
|
||||||
typeFilter: const {},
|
typeFilter: const {},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import '../state/trust_cubit.dart';
|
||||||
import 'market_gate.dart';
|
import 'market_gate.dart';
|
||||||
import 'peer_avatar.dart';
|
import 'peer_avatar.dart';
|
||||||
import 'rating_sheet.dart';
|
import 'rating_sheet.dart';
|
||||||
|
import 'report_sheet.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
||||||
|
|
@ -202,6 +203,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
widget.settings ??
|
widget.settings ??
|
||||||
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
(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,
|
/// Blocks this peer after confirmation and leaves the chat; their offers,
|
||||||
/// conversation and future messages disappear (all locally).
|
/// conversation and future messages disappear (all locally).
|
||||||
Future<void> _blockPeer() async {
|
Future<void> _blockPeer() async {
|
||||||
|
|
@ -287,19 +302,24 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
tooltip: t.chat.payG1,
|
tooltip: t.chat.payG1,
|
||||||
onPressed: _payG1,
|
onPressed: _payG1,
|
||||||
),
|
),
|
||||||
if (_settings != null)
|
PopupMenuButton<String>(
|
||||||
PopupMenuButton<String>(
|
key: const Key('chat.menu'),
|
||||||
key: const Key('chat.menu'),
|
onSelected: (value) {
|
||||||
onSelected: (value) {
|
if (value == 'report') _reportPeer();
|
||||||
if (value == 'block') _blockPeer();
|
if (value == 'block') _blockPeer();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'report',
|
||||||
|
child: Text(t.report.person),
|
||||||
|
),
|
||||||
|
if (_settings != null)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'block',
|
value: 'block',
|
||||||
child: Text(t.block.action),
|
child: Text(t.block.action),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _loading || messages == null || trust == null || rating == null
|
body: _loading || messages == null || trust == null || rating == null
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../state/peer_rating_cubit.dart';
|
||||||
import 'market_widgets.dart';
|
import 'market_widgets.dart';
|
||||||
import 'peer_avatar.dart';
|
import 'peer_avatar.dart';
|
||||||
import 'rating_sheet.dart';
|
import 'rating_sheet.dart';
|
||||||
|
import 'report_sheet.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
||||||
|
|
@ -172,6 +173,26 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
widget.settings ??
|
widget.settings ??
|
||||||
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
(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
|
/// Blocks the offer's author after confirmation and goes back to the market
|
||||||
/// (which reloads the blocklist and drops their offers).
|
/// (which reloads the blocklist and drops their offers).
|
||||||
Future<void> _blockAuthor() async {
|
Future<void> _blockAuthor() async {
|
||||||
|
|
@ -231,17 +252,23 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
||||||
onPressed: _toggleSaved,
|
onPressed: _toggleSaved,
|
||||||
),
|
),
|
||||||
if (!widget.mine && _settings != null)
|
if (!widget.mine)
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
key: const Key('offer.menu'),
|
key: const Key('offer.menu'),
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
|
if (value == 'report') _reportOffer();
|
||||||
if (value == 'block') _blockAuthor();
|
if (value == 'block') _blockAuthor();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'block',
|
value: 'report',
|
||||||
child: Text(t.block.action),
|
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 =
|
final cubit =
|
||||||
await createOffersCubit(widget.connection, repository: repo);
|
await createOffersCubit(widget.connection, repository: repo);
|
||||||
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||||
|
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await cubit.close();
|
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
|
/// Re-reads the blocklist and hidden (reported) offers — the offer detail
|
||||||
/// offers drop out of the visible list at once.
|
/// can change both — so they drop out of the visible list at once.
|
||||||
Future<void> _reloadBlocked() async {
|
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 {
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -92,4 +92,13 @@ void main() {
|
||||||
await settings.block(' AB' * 1 + 'ab' * 31); // messy spacing/case
|
await settings.block(' AB' * 1 + 'ab' * 31); // messy spacing/case
|
||||||
expect(await settings.blockedPubkeys(), hasLength(1));
|
expect(await settings.blockedPubkeys(), hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('hidden (reported) offers round-trip as author:id keys', () async {
|
||||||
|
expect(await settings.hiddenOfferKeys(), isEmpty);
|
||||||
|
await settings.hideOffer(authorPubkeyHex: 'AB' * 32, offerId: 'tomate-1');
|
||||||
|
expect(
|
||||||
|
await settings.hiddenOfferKeys(),
|
||||||
|
{SocialSettings.offerKey('ab' * 32, 'tomate-1')},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,28 @@ void main() {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a hidden (reported) offer disappears without touching its author',
|
||||||
|
() async {
|
||||||
|
final transport = FakeOfferTransport();
|
||||||
|
for (final id in ['lot-1', 'lot-2']) {
|
||||||
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: id,
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Oferta $id',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final cubit = OffersCubit(transport);
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(cubit.state.visibleOffers, hasLength(2));
|
||||||
|
|
||||||
|
cubit.setHiddenOffers({'${'ab' * 32}:lot-1'});
|
||||||
|
expect(cubit.state.visibleOffers.map((o) => o.id), ['lot-2']);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('the search filter survives a refresh (re-discovery)', () async {
|
test('the search filter survives a refresh (re-discovery)', () async {
|
||||||
final transport = FakeOfferTransport();
|
final transport = FakeOfferTransport();
|
||||||
await transport.publish(OfferMapper.fromSharedLot(
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
|
|
||||||
74
apps/app_seeds/test/ui/report_sheet_test.dart
Normal file
74
apps/app_seeds/test/ui/report_sheet_test.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/social_connection.dart';
|
||||||
|
import 'package:tane/services/social_service.dart';
|
||||||
|
import 'package:tane/services/social_settings.dart';
|
||||||
|
import 'package:tane/ui/report_sheet.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const seedHex =
|
||||||
|
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||||
|
|
||||||
|
// A connection that can never open — the sheet degrades to "couldn't send".
|
||||||
|
Future<SocialConnection> offlineConnection() async => SocialConnection(
|
||||||
|
social: await SocialService.fromRootSeedHex(seedHex),
|
||||||
|
settings: SocialSettings(InMemorySecretStore()),
|
||||||
|
open: (_) async => throw StateError('offline'),
|
||||||
|
online: const Stream.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget host(SocialConnection connection, SocialSettings settings) =>
|
||||||
|
TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ReportSheet(
|
||||||
|
connection: connection,
|
||||||
|
subjectPubkey: 'ab' * 32,
|
||||||
|
settings: settings,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('shows the reason picker, details field and block option', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
host(await offlineConnection(), SocialSettings(InMemorySecretStore())),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('Spam or a scam'), findsOneWidget);
|
||||||
|
expect(find.text('Abusive or disrespectful'), findsOneWidget);
|
||||||
|
expect(find.text("Seeds that shouldn't be offered"), findsOneWidget);
|
||||||
|
expect(find.text('Something else'), findsOneWidget);
|
||||||
|
expect(find.text('Also block this person'), findsOneWidget);
|
||||||
|
expect(find.text('Send report'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('offline send fails softly and keeps the sheet open', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
|
await tester.pumpWidget(host(await offlineConnection(), settings));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('report.send')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text("Couldn't send the report — check your connection"),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// Nothing was blocked as a side effect of the failed send.
|
||||||
|
expect(await settings.blockedPubkeys(), isEmpty);
|
||||||
|
expect(find.byType(ReportSheet), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,8 @@ class FakeSession implements SocialSession {
|
||||||
@override
|
@override
|
||||||
RatingTransport get ratings => throw UnimplementedError();
|
RatingTransport get ratings => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
|
ReportTransport get reports => throw UnimplementedError();
|
||||||
|
@override
|
||||||
ProfileTransport get profile => throw UnimplementedError();
|
ProfileTransport get profile => throw UnimplementedError();
|
||||||
@override
|
@override
|
||||||
SyncTransport get sync => throw UnimplementedError();
|
SyncTransport get sync => throw UnimplementedError();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export 'src/social/certification.dart';
|
||||||
export 'src/social/geohash.dart';
|
export 'src/social/geohash.dart';
|
||||||
export 'src/social/message_transport.dart';
|
export 'src/social/message_transport.dart';
|
||||||
export 'src/social/nostr_ids.dart';
|
export 'src/social/nostr_ids.dart';
|
||||||
|
export 'src/social/nostr/nip99_codec.dart';
|
||||||
export 'src/social/nostr/nostr_channel.dart';
|
export 'src/social/nostr/nostr_channel.dart';
|
||||||
export 'src/social/nostr/nostr_sync_transport.dart';
|
export 'src/social/nostr/nostr_sync_transport.dart';
|
||||||
export 'src/social/nostr/nostr_connection.dart';
|
export 'src/social/nostr/nostr_connection.dart';
|
||||||
|
|
@ -22,6 +23,7 @@ export 'src/social/nostr/nostr_message_transport.dart';
|
||||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||||
export 'src/social/nostr/nostr_profile_transport.dart';
|
export 'src/social/nostr/nostr_profile_transport.dart';
|
||||||
export 'src/social/nostr/nostr_rating_transport.dart';
|
export 'src/social/nostr/nostr_rating_transport.dart';
|
||||||
|
export 'src/social/nostr/nostr_report_transport.dart';
|
||||||
export 'src/social/nostr/nostr_trust_transport.dart';
|
export 'src/social/nostr/nostr_trust_transport.dart';
|
||||||
export 'src/social/nostr/relay_pool.dart';
|
export 'src/social/nostr/relay_pool.dart';
|
||||||
export 'src/social/offer.dart';
|
export 'src/social/offer.dart';
|
||||||
|
|
@ -29,6 +31,7 @@ export 'src/social/offer_transport.dart';
|
||||||
export 'src/social/profile_transport.dart';
|
export 'src/social/profile_transport.dart';
|
||||||
export 'src/social/rating.dart';
|
export 'src/social/rating.dart';
|
||||||
export 'src/social/rating_transport.dart';
|
export 'src/social/rating_transport.dart';
|
||||||
|
export 'src/social/report_transport.dart';
|
||||||
export 'src/social/sync_transport.dart';
|
export 'src/social/sync_transport.dart';
|
||||||
export 'src/social/trust_transport.dart';
|
export 'src/social/trust_transport.dart';
|
||||||
export 'src/social/web_of_trust.dart';
|
export 'src/social/web_of_trust.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:nostr/nostr.dart';
|
||||||
|
|
||||||
|
import '../report_transport.dart';
|
||||||
|
import 'nostr_channel.dart';
|
||||||
|
|
||||||
|
/// Reports carried as standard NIP-56 events on the shared [NostrConnection]:
|
||||||
|
/// kind 1984, the reported key in a typed `p` tag (and the offending event in
|
||||||
|
/// a typed `e` tag when given), free text in the content. Standard on purpose
|
||||||
|
/// so any relay operator or moderation tool can act on them.
|
||||||
|
class NostrReportTransport implements ReportTransport {
|
||||||
|
NostrReportTransport(this._conn);
|
||||||
|
|
||||||
|
final NostrChannel _conn;
|
||||||
|
|
||||||
|
/// NIP-56 reporting kind.
|
||||||
|
static const kindReport = 1984;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> report({
|
||||||
|
required String subjectPubkey,
|
||||||
|
String? eventId,
|
||||||
|
String? eventAddress,
|
||||||
|
required ReportReason reason,
|
||||||
|
String details = '',
|
||||||
|
}) async {
|
||||||
|
final type = reason.name;
|
||||||
|
final event = Event.from(
|
||||||
|
kind: kindReport,
|
||||||
|
content: details,
|
||||||
|
tags: [
|
||||||
|
if (eventId != null) ['e', eventId, type],
|
||||||
|
if (eventAddress != null) ['a', eventAddress, type],
|
||||||
|
['p', subjectPubkey, type],
|
||||||
|
],
|
||||||
|
secretKey: _conn.privateKeyHex,
|
||||||
|
);
|
||||||
|
final r = await _conn.publish(event);
|
||||||
|
if (!r.accepted) throw StateError('relay rejected report: ${r.message}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() => _conn.close();
|
||||||
|
}
|
||||||
23
packages/commons_core/lib/src/social/report_transport.dart
Normal file
23
packages/commons_core/lib/src/social/report_transport.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/// The standard report categories (NIP-56). Kept as an enum so the app maps
|
||||||
|
/// its human wording onto interoperable values.
|
||||||
|
enum ReportReason { nudity, malware, profanity, illegal, spam, impersonation, other }
|
||||||
|
|
||||||
|
/// Flagging bad actors/content over the shared connection. Reports are public
|
||||||
|
/// signed statements ("this key/event breaks the rules") that relays and
|
||||||
|
/// moderation tools act on — the network-side half of the report/block story
|
||||||
|
/// (the local half is each user's own blocklist, which lives in the app).
|
||||||
|
abstract interface class ReportTransport {
|
||||||
|
/// Publishes a report about [subjectPubkey]; when the complaint is about one
|
||||||
|
/// specific event pass its [eventId], or — for addressable content such as
|
||||||
|
/// offers — its address ([eventAddress], `kind:pubkey:d`). [details] is
|
||||||
|
/// optional free text for a human moderator.
|
||||||
|
Future<void> report({
|
||||||
|
required String subjectPubkey,
|
||||||
|
String? eventId,
|
||||||
|
String? eventAddress,
|
||||||
|
required ReportReason reason,
|
||||||
|
String details = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:nostr/nostr.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../support/mini_relay.dart';
|
||||||
|
|
||||||
|
/// Reports are standard NIP-56 events (kind 1984): a `p` tag naming the
|
||||||
|
/// reported key (with the report type), optionally an `e` tag naming the
|
||||||
|
/// offending event, and free text in the content. Standard on purpose — any
|
||||||
|
/// Nostr relay or moderation tool understands them.
|
||||||
|
void main() {
|
||||||
|
late MiniRelay relay;
|
||||||
|
setUp(() async => relay = await MiniRelay.start());
|
||||||
|
tearDown(() async => relay.stop());
|
||||||
|
|
||||||
|
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
|
||||||
|
Uint8List(32)..fillRange(0, 32, fill),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('reporting a person publishes a kind-1984 event with the typed p tag',
|
||||||
|
() async {
|
||||||
|
final alice = await idFor(1);
|
||||||
|
final conn = await NostrConnection.connect(relay.url, identity: alice);
|
||||||
|
final reports = NostrReportTransport(conn);
|
||||||
|
|
||||||
|
await reports.report(
|
||||||
|
subjectPubkey: 'ab' * 32,
|
||||||
|
reason: ReportReason.spam,
|
||||||
|
details: 'floods the market with fake offers',
|
||||||
|
);
|
||||||
|
|
||||||
|
final stored = await conn
|
||||||
|
.reqOnce(Filter(kinds: const [NostrReportTransport.kindReport]));
|
||||||
|
expect(stored, hasLength(1));
|
||||||
|
final event = stored.single;
|
||||||
|
expect(event.pubkey, alice.publicKeyHex);
|
||||||
|
expect(event.content, 'floods the market with fake offers');
|
||||||
|
expect(event.tags, anyElement(equals(['p', 'ab' * 32, 'spam'])));
|
||||||
|
await reports.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reporting an offending event adds the typed e tag too', () async {
|
||||||
|
final alice = await idFor(1);
|
||||||
|
final conn = await NostrConnection.connect(relay.url, identity: alice);
|
||||||
|
final reports = NostrReportTransport(conn);
|
||||||
|
|
||||||
|
await reports.report(
|
||||||
|
subjectPubkey: 'cd' * 32,
|
||||||
|
eventId: 'ef' * 32,
|
||||||
|
reason: ReportReason.illegal,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stored = await conn
|
||||||
|
.reqOnce(Filter(kinds: const [NostrReportTransport.kindReport]));
|
||||||
|
final event = stored.single;
|
||||||
|
expect(event.tags, anyElement(equals(['e', 'ef' * 32, 'illegal'])));
|
||||||
|
expect(event.tags, anyElement(equals(['p', 'cd' * 32, 'illegal'])));
|
||||||
|
expect(event.content, isEmpty);
|
||||||
|
await reports.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reporting addressable content (an offer) adds the typed a tag',
|
||||||
|
() async {
|
||||||
|
final alice = await idFor(1);
|
||||||
|
final conn = await NostrConnection.connect(relay.url, identity: alice);
|
||||||
|
final reports = NostrReportTransport(conn);
|
||||||
|
final address = '30402:${'cd' * 32}:tomate-1';
|
||||||
|
|
||||||
|
await reports.report(
|
||||||
|
subjectPubkey: 'cd' * 32,
|
||||||
|
eventAddress: address,
|
||||||
|
reason: ReportReason.spam,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stored = await conn
|
||||||
|
.reqOnce(Filter(kinds: const [NostrReportTransport.kindReport]));
|
||||||
|
expect(
|
||||||
|
stored.single.tags,
|
||||||
|
anyElement(equals(['a', address, 'spam'])),
|
||||||
|
);
|
||||||
|
await reports.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue