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
b41dfd4248
commit
7869a7163b
15 changed files with 560 additions and 15 deletions
|
|
@ -97,6 +97,7 @@ class SocialSession {
|
|||
messages = NostrMessageTransport(_channel),
|
||||
trust = NostrTrustTransport(_channel),
|
||||
ratings = NostrRatingTransport(_channel),
|
||||
reports = NostrReportTransport(_channel),
|
||||
profile = NostrProfileTransport(_channel),
|
||||
sync = NostrSyncTransport(
|
||||
_channel,
|
||||
|
|
@ -110,6 +111,7 @@ class SocialSession {
|
|||
final MessageTransport messages;
|
||||
final TrustTransport trust;
|
||||
final RatingTransport ratings;
|
||||
final ReportTransport reports;
|
||||
final ProfileTransport profile;
|
||||
|
||||
/// 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 _searchPrecisionKey = 'tane.social.search_precision';
|
||||
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
|
||||
/// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region").
|
||||
|
|
@ -109,4 +110,25 @@ class SocialSettings {
|
|||
..remove(pubkeyHex.trim().toLowerCase());
|
||||
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.organicOnly = false,
|
||||
this.blockedAuthors = const {},
|
||||
this.hiddenOfferKeys = const {},
|
||||
this.searching = false,
|
||||
this.publishing = false,
|
||||
this.hasSearched = false,
|
||||
|
|
@ -51,6 +52,10 @@ class OffersState extends Equatable {
|
|||
/// already discovered.
|
||||
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 publishing;
|
||||
|
||||
|
|
@ -67,6 +72,9 @@ class OffersState extends Equatable {
|
|||
final q = query.trim().toLowerCase();
|
||||
return offers.where((o) {
|
||||
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 (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
||||
if (categoryFilter.isNotEmpty &&
|
||||
|
|
@ -104,6 +112,7 @@ class OffersState extends Equatable {
|
|||
Set<String>? categoryFilter,
|
||||
bool? organicOnly,
|
||||
Set<String>? blockedAuthors,
|
||||
Set<String>? hiddenOfferKeys,
|
||||
bool? searching,
|
||||
bool? publishing,
|
||||
bool? hasSearched,
|
||||
|
|
@ -117,6 +126,7 @@ class OffersState extends Equatable {
|
|||
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||
organicOnly: organicOnly ?? this.organicOnly,
|
||||
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
||||
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
|
||||
searching: searching ?? this.searching,
|
||||
publishing: publishing ?? this.publishing,
|
||||
hasSearched: hasSearched ?? this.hasSearched,
|
||||
|
|
@ -133,6 +143,7 @@ class OffersState extends Equatable {
|
|||
categoryFilter,
|
||||
organicOnly,
|
||||
blockedAuthors,
|
||||
hiddenOfferKeys,
|
||||
searching,
|
||||
publishing,
|
||||
hasSearched,
|
||||
|
|
@ -191,6 +202,7 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
categoryFilter: state.categoryFilter,
|
||||
organicOnly: state.organicOnly,
|
||||
blockedAuthors: state.blockedAuthors,
|
||||
hiddenOfferKeys: state.hiddenOfferKeys,
|
||||
searching: true,
|
||||
hasSearched: true,
|
||||
));
|
||||
|
|
@ -250,6 +262,10 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
void setBlockedAuthors(Set<String> 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).
|
||||
void clearFilters() => emit(state.copyWith(
|
||||
typeFilter: const {},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -92,4 +92,13 @@ void main() {
|
|||
await settings.block(' AB' * 1 + 'ab' * 31); // messy spacing/case
|
||||
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();
|
||||
});
|
||||
|
||||
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 {
|
||||
final transport = FakeOfferTransport();
|
||||
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
|
||||
RatingTransport get ratings => throw UnimplementedError();
|
||||
@override
|
||||
ReportTransport get reports => throw UnimplementedError();
|
||||
@override
|
||||
ProfileTransport get profile => throw UnimplementedError();
|
||||
@override
|
||||
SyncTransport get sync => throw UnimplementedError();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export 'src/social/certification.dart';
|
|||
export 'src/social/geohash.dart';
|
||||
export 'src/social/message_transport.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_sync_transport.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_profile_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/relay_pool.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/rating.dart';
|
||||
export 'src/social/rating_transport.dart';
|
||||
export 'src/social/report_transport.dart';
|
||||
export 'src/social/sync_transport.dart';
|
||||
export 'src/social/trust_transport.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