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

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

View file

@ -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.

View file

@ -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'));
}
}

View file

@ -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 {},

View file

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

View file

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

View file

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

View file

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

View file

@ -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')},
);
});
}

View file

@ -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(

View 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);
});
}

View file

@ -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();

View file

@ -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';

View file

@ -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();
}

View 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();
}

View file

@ -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();
});
}