From 7869a7163b7d5f54abf30d3bb7a590359a2ad591 Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 13 Jul 2026 07:56:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(social):=20standard=20NIP-56=20reporting?= =?UTF-8?q?=20=E2=80=94=20ReportTransport=20in=20commons=5Fcore,=20report?= =?UTF-8?q?=20sheet=20on=20offers=20and=20chats,=20local=20hide=20+=20opti?= =?UTF-8?q?onal=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/services/social_service.dart | 2 + .../lib/services/social_settings.dart | 22 ++ apps/app_seeds/lib/state/offers_cubit.dart | 16 ++ apps/app_seeds/lib/ui/chat_screen.dart | 38 +++- .../lib/ui/market_offer_detail_screen.dart | 33 ++- apps/app_seeds/lib/ui/market_screen.dart | 10 +- apps/app_seeds/lib/ui/report_sheet.dart | 193 ++++++++++++++++++ .../test/services/social_settings_test.dart | 9 + .../test/state/offers_cubit_test.dart | 22 ++ apps/app_seeds/test/ui/report_sheet_test.dart | 74 +++++++ .../test/ui/your_people_screen_test.dart | 2 + packages/commons_core/lib/commons_core.dart | 3 + .../social/nostr/nostr_report_transport.dart | 43 ++++ .../lib/src/social/report_transport.dart | 23 +++ .../social/nostr_report_transport_test.dart | 85 ++++++++ 15 files changed, 560 insertions(+), 15 deletions(-) create mode 100644 apps/app_seeds/lib/ui/report_sheet.dart create mode 100644 apps/app_seeds/test/ui/report_sheet_test.dart create mode 100644 packages/commons_core/lib/src/social/nostr/nostr_report_transport.dart create mode 100644 packages/commons_core/lib/src/social/report_transport.dart create mode 100644 packages/commons_core/test/social/nostr_report_transport_test.dart diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index 2c8b863..339be8c 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -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. diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index ceffe05..8dbe023 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -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> hiddenOfferKeys() async { + final raw = await _store.read(_hiddenOffersKey); + if (raw == null) return {}; + return raw.split('\n').where((s) => s.trim().isNotEmpty).toSet(); + } + + Future hideOffer({ + required String authorPubkeyHex, + required String offerId, + }) async { + final set = await hiddenOfferKeys() + ..add(offerKey(authorPubkeyHex, offerId)); + await _store.write(_hiddenOffersKey, set.join('\n')); + } } diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 4f7521a..8aa6e9d 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -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 blockedAuthors; + /// Individual offers this user reported and hid ("author:id" keys, see + /// SocialSettings.offerKey). Same idea as [blockedAuthors], finer grain. + final Set 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? categoryFilter, bool? organicOnly, Set? blockedAuthors, + Set? 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 { categoryFilter: state.categoryFilter, organicOnly: state.organicOnly, blockedAuthors: state.blockedAuthors, + hiddenOfferKeys: state.hiddenOfferKeys, searching: true, hasSearched: true, )); @@ -250,6 +262,10 @@ class OffersCubit extends Cubit { void setBlockedAuthors(Set pubkeys) => emit(state.copyWith(blockedAuthors: pubkeys)); + /// Replaces the set of locally hidden (reported) offers. + void setHiddenOffers(Set offerKeys) => + emit(state.copyWith(hiddenOfferKeys: offerKeys)); + /// Clears every chip filter (leaves the text search untouched). void clearFilters() => emit(state.copyWith( typeFilter: const {}, diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 92ffd6b..a48026c 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -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 { widget.settings ?? (getIt.isRegistered() ? getIt() : 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 _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 _blockPeer() async { @@ -287,19 +302,24 @@ class _ChatScreenState extends State { tooltip: t.chat.payG1, onPressed: _payG1, ), - if (_settings != null) - PopupMenuButton( - key: const Key('chat.menu'), - onSelected: (value) { - if (value == 'block') _blockPeer(); - }, - itemBuilder: (context) => [ + PopupMenuButton( + 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 diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart index 7f0058b..675742d 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -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 { widget.settings ?? (getIt.isRegistered() ? getIt() : null); + /// Reports this offer (a standard report event to the community servers), + /// hides it locally, and goes back to the market. + Future _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 _blockAuthor() async { @@ -231,17 +252,23 @@ class _MarketOfferDetailScreenState extends State { tooltip: saved ? t.favorites.remove : t.favorites.save, onPressed: _toggleSaved, ), - if (!widget.mine && _settings != null) + if (!widget.mine) PopupMenuButton( 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), + ), ], ), ], diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 439231c..f66ffe1 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -94,6 +94,7 @@ class _MarketScreenState extends State { 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 { } } - /// 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 _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 _openConfig() async { diff --git a/apps/app_seeds/lib/ui/report_sheet.dart b/apps/app_seeds/lib/ui/report_sheet.dart new file mode 100644 index 0000000..ca4ae05 --- /dev/null +++ b/apps/app_seeds/lib/ui/report_sheet.dart @@ -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 showReportSheet( + BuildContext context, { + required SocialConnection connection, + required String subjectPubkey, + String? offerAddress, + SocialSettings? settings, +}) { + return showModalBottomSheet( + 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 createState() => _ReportSheetState(); +} + +class _ReportSheetState extends State { + ReportReason _reason = ReportReason.spam; + bool _alsoBlock = false; + bool _sending = false; + final _details = TextEditingController(); + + @override + void dispose() { + _details.dispose(); + super.dispose(); + } + + Future _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( + groupValue: _reason, + onChanged: _sending + ? (_) {} + : (v) => setState(() => _reason = v ?? _reason), + child: Column( + children: [ + for (final (value, label) in reasons) + RadioListTile( + 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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index 6376e91..c3abe43 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -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')}, + ); + }); } diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 64ddc1a..d38a96e 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -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( diff --git a/apps/app_seeds/test/ui/report_sheet_test.dart b/apps/app_seeds/test/ui/report_sheet_test.dart new file mode 100644 index 0000000..4f5bbf6 --- /dev/null +++ b/apps/app_seeds/test/ui/report_sheet_test.dart @@ -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 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); + }); +} diff --git a/apps/app_seeds/test/ui/your_people_screen_test.dart b/apps/app_seeds/test/ui/your_people_screen_test.dart index 4973282..d868fba 100644 --- a/apps/app_seeds/test/ui/your_people_screen_test.dart +++ b/apps/app_seeds/test/ui/your_people_screen_test.dart @@ -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(); diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index e7ab5ec..23c5ad8 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -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'; diff --git a/packages/commons_core/lib/src/social/nostr/nostr_report_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_report_transport.dart new file mode 100644 index 0000000..24ed3fd --- /dev/null +++ b/packages/commons_core/lib/src/social/nostr/nostr_report_transport.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 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 close() => _conn.close(); +} diff --git a/packages/commons_core/lib/src/social/report_transport.dart b/packages/commons_core/lib/src/social/report_transport.dart new file mode 100644 index 0000000..37a42dc --- /dev/null +++ b/packages/commons_core/lib/src/social/report_transport.dart @@ -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 report({ + required String subjectPubkey, + String? eventId, + String? eventAddress, + required ReportReason reason, + String details = '', + }); + + Future close(); +} diff --git a/packages/commons_core/test/social/nostr_report_transport_test.dart b/packages/commons_core/test/social/nostr_report_transport_test.dart new file mode 100644 index 0000000..0eeec18 --- /dev/null +++ b/packages/commons_core/test/social/nostr_report_transport_test.dart @@ -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 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(); + }); +}