From 11b242edbf40f7feec7f0b5e2020e79ca7461a51 Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 13 Jul 2026 01:03:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(social):=20local=20blocklist=20=E2=80=94?= =?UTF-8?q?=20hide=20blocked=20authors'=20offers,=20chats=20and=20incoming?= =?UTF-8?q?=20messages,=20with=20unblock=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app_seeds/lib/app.dart | 3 + apps/app_seeds/lib/di/injector.dart | 2 + .../app_seeds/lib/services/inbox_service.dart | 13 ++- .../lib/services/social_settings.dart | 29 +++++ apps/app_seeds/lib/state/offers_cubit.dart | 16 +++ apps/app_seeds/lib/ui/blocked_people.dart | 109 ++++++++++++++++++ apps/app_seeds/lib/ui/chat_list_screen.dart | 12 +- apps/app_seeds/lib/ui/chat_screen.dart | 56 +++++++++ .../lib/ui/market_offer_detail_screen.dart | 56 +++++++++ apps/app_seeds/lib/ui/market_screen.dart | 33 +++++- .../test/services/inbox_service_test.dart | 17 +++ .../test/services/social_settings_test.dart | 21 ++++ .../test/state/offers_cubit_test.dart | 36 ++++++ 13 files changed, 399 insertions(+), 4 deletions(-) create mode 100644 apps/app_seeds/lib/ui/blocked_people.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 80a87bc..21c296c 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -184,6 +184,7 @@ class TaneApp extends StatelessWidget { profileCache: profileCache, selfPubkey: social.publicKeyHex, savedOffers: savedOffers, + settings: socialSettings, ); }, ), @@ -203,6 +204,7 @@ class TaneApp extends StatelessWidget { connection: connection, profileCache: profileCache, inbox: inbox, + settings: socialSettings, ), ), if (social != null && @@ -238,6 +240,7 @@ class TaneApp extends StatelessWidget { messageStore: messageStore, profileCache: profileCache, onboarding: onboarding, + settings: socialSettings, ), ), GoRoute( diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index e2f3084..0bf659d 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -249,6 +249,7 @@ Future configureDependencies() async { profileCache: getIt(), unread: getIt(), notifications: getIt(), + settings: getIt(), ), ) // Replicates the inventory across this identity's own devices over the @@ -342,6 +343,7 @@ Future switchSocialAccount(int account) async { profileCache: getIt(), unread: getIt(), notifications: getIt(), + settings: getIt(), ); final sync = SyncService( connection: connection, diff --git a/apps/app_seeds/lib/services/inbox_service.dart b/apps/app_seeds/lib/services/inbox_service.dart index 832360a..f629810 100644 --- a/apps/app_seeds/lib/services/inbox_service.dart +++ b/apps/app_seeds/lib/services/inbox_service.dart @@ -9,6 +9,7 @@ import 'notification_service.dart'; import 'profile_cache.dart'; import 'social_connection.dart'; import 'social_service.dart' show SocialSession; +import 'social_settings.dart'; import 'unread_service.dart'; /// App-wide inbox listener for private messages (NIP-17). @@ -31,12 +32,14 @@ class InboxService { ProfileCache? profileCache, UnreadService? unread, NotificationService? notifications, + SocialSettings? settings, }) : _connection = connection, _selfPubkey = selfPubkey, _store = store, _profileCache = profileCache, _unread = unread, - _notifications = notifications; + _notifications = notifications, + _settings = settings; final SocialConnection _connection; final String _selfPubkey; @@ -45,6 +48,10 @@ class InboxService { final UnreadService? _unread; final NotificationService? _notifications; + /// Holds the local blocklist; messages from blocked peers are dropped before + /// they are stored or notified. Null in tests → nothing filtered. + final SocialSettings? _settings; + final _changes = StreamController.broadcast(); StreamSubscription? _sessionsSub; StreamSubscription? _inboxSub; @@ -86,6 +93,10 @@ class InboxService { /// testable seam (no relay). @visibleForTesting Future ingest(PrivateMessage message) async { + final settings = _settings; + if (settings != null && await settings.isBlocked(message.fromPubkey)) { + return; // blocked peer — dropped before storage, unread or notification + } final stored = await _store.append(message.fromPubkey, message); if (!stored) return; // a re-delivered duplicate if (!_changes.isClosed) _changes.add(null); diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 6cdcb7a..ceffe05 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -17,6 +17,7 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; static const _searchPrecisionKey = 'tane.social.search_precision'; + static const _blockedKey = 'tane.social.blocked_pubkeys'; /// 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"). @@ -80,4 +81,32 @@ class SocialSettings { /// Whether the social layer has enough config to attempt going online. Future get isConfigured async => (await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty; + + /// Pubkeys (hex) this user has blocked: their offers are hidden, their + /// chats disappear and their incoming messages are dropped. Purely local — + /// nothing is published about who you block. + Future> blockedPubkeys() async { + final raw = await _store.read(_blockedKey); + if (raw == null) return {}; + return raw + .split('\n') + .map((s) => s.trim().toLowerCase()) + .where((s) => s.isNotEmpty) + .toSet(); + } + + Future isBlocked(String pubkeyHex) async => + (await blockedPubkeys()).contains(pubkeyHex.trim().toLowerCase()); + + Future block(String pubkeyHex) async { + final set = await blockedPubkeys() + ..add(pubkeyHex.trim().toLowerCase()); + await _store.write(_blockedKey, set.join('\n')); + } + + Future unblock(String pubkeyHex) async { + final set = await blockedPubkeys() + ..remove(pubkeyHex.trim().toLowerCase()); + await _store.write(_blockedKey, set.join('\n')); + } } diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index d07c6b6..4f7521a 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -21,6 +21,7 @@ class OffersState extends Equatable { this.typeFilter = const {}, this.categoryFilter = const {}, this.organicOnly = false, + this.blockedAuthors = const {}, this.searching = false, this.publishing = false, this.hasSearched = false, @@ -45,6 +46,11 @@ class OffersState extends Equatable { /// When true, show only offers the grower declared organic ("eco"). final bool organicOnly; + /// Authors this user has blocked; their offers never surface. Kept in the + /// state (not dropped at merge time) so unblocking can resurface what was + /// already discovered. + final Set blockedAuthors; + final bool searching; final bool publishing; @@ -60,6 +66,7 @@ class OffersState extends Equatable { List get visibleOffers { final q = query.trim().toLowerCase(); return offers.where((o) { + if (blockedAuthors.contains(o.authorPubkeyHex)) return false; if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false; if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false; if (categoryFilter.isNotEmpty && @@ -96,6 +103,7 @@ class OffersState extends Equatable { Set? typeFilter, Set? categoryFilter, bool? organicOnly, + Set? blockedAuthors, bool? searching, bool? publishing, bool? hasSearched, @@ -108,6 +116,7 @@ class OffersState extends Equatable { typeFilter: typeFilter ?? this.typeFilter, categoryFilter: categoryFilter ?? this.categoryFilter, organicOnly: organicOnly ?? this.organicOnly, + blockedAuthors: blockedAuthors ?? this.blockedAuthors, searching: searching ?? this.searching, publishing: publishing ?? this.publishing, hasSearched: hasSearched ?? this.hasSearched, @@ -123,6 +132,7 @@ class OffersState extends Equatable { typeFilter, categoryFilter, organicOnly, + blockedAuthors, searching, publishing, hasSearched, @@ -180,6 +190,7 @@ class OffersCubit extends Cubit { typeFilter: state.typeFilter, categoryFilter: state.categoryFilter, organicOnly: state.organicOnly, + blockedAuthors: state.blockedAuthors, searching: true, hasSearched: true, )); @@ -234,6 +245,11 @@ class OffersCubit extends Cubit { void toggleOrganicOnly() => emit(state.copyWith(organicOnly: !state.organicOnly)); + /// Replaces the set of blocked authors (loaded from the local blocklist); + /// their offers disappear from the visible list at once. + void setBlockedAuthors(Set pubkeys) => + emit(state.copyWith(blockedAuthors: pubkeys)); + /// Clears every chip filter (leaves the text search untouched). void clearFilters() => emit(state.copyWith( typeFilter: const {}, diff --git a/apps/app_seeds/lib/ui/blocked_people.dart b/apps/app_seeds/lib/ui/blocked_people.dart new file mode 100644 index 0000000..5982dd2 --- /dev/null +++ b/apps/app_seeds/lib/ui/blocked_people.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; + +import '../di/injector.dart'; +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart'; +import '../services/social_settings.dart'; +import 'peer_avatar.dart'; +import 'theme.dart'; + +/// Management list for the local blocklist: who is blocked, with one-tap +/// unblock. Opened from the market's sharing setup. +class BlockedPeopleSheet extends StatefulWidget { + const BlockedPeopleSheet({required this.settings, this.profileCache, super.key}); + + final SocialSettings settings; + + /// Resolves peers' display names; falls back to the app-wide cache when not + /// injected (tests pass one explicitly or leave names as short keys). + final ProfileCache? profileCache; + + @override + State createState() => _BlockedPeopleSheetState(); +} + +class _BlockedPeopleSheetState extends State { + List? _blocked; + final Map _names = {}; + + ProfileCache? get _cache => + widget.profileCache ?? + (getIt.isRegistered() ? getIt() : null); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final blocked = (await widget.settings.blockedPubkeys()).toList()..sort(); + final cache = _cache; + if (cache != null) { + for (final p in blocked) { + final name = await cache.name(p); + if (name != null) _names[p] = name; + } + } + if (mounted) setState(() => _blocked = blocked); + } + + Future _unblock(String pubkey) async { + await widget.settings.unblock(pubkey); + await _load(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final blocked = _blocked; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.block.manageTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + if (blocked == null) + const Center(child: CircularProgressIndicator()) + else if (blocked.isEmpty) + Text( + t.block.manageEmpty, + style: const TextStyle(color: seedMuted), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: blocked.length, + itemBuilder: (context, i) { + final pubkey = blocked[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: PeerAvatar( + pubkey: pubkey, + name: _names[pubkey], + ), + title: Text(_names[pubkey] ?? shortPubkey(pubkey)), + trailing: TextButton( + onPressed: () => _unblock(pubkey), + child: Text(t.block.unblock), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/chat_list_screen.dart b/apps/app_seeds/lib/ui/chat_list_screen.dart index ea4fdc6..15559f4 100644 --- a/apps/app_seeds/lib/ui/chat_list_screen.dart +++ b/apps/app_seeds/lib/ui/chat_list_screen.dart @@ -9,6 +9,7 @@ import '../services/message_store.dart'; import '../services/profile_cache.dart'; import 'peer_avatar.dart'; import '../services/social_connection.dart'; +import '../services/social_settings.dart'; import 'theme.dart'; import 'unread_badge.dart'; @@ -20,6 +21,7 @@ class ChatListScreen extends StatefulWidget { this.connection, this.profileCache, this.inbox, + this.settings, super.key, }); @@ -32,6 +34,10 @@ class ChatListScreen extends StatefulWidget { /// App-wide inbox listener; the list reloads whenever it reports a change. final InboxService? inbox; + /// Social settings holding the local blocklist; blocked peers' conversations + /// are hidden. Null in tests → nothing filtered. + final SocialSettings? settings; + @override State createState() => _ChatListScreenState(); } @@ -56,7 +62,11 @@ class _ChatListScreenState extends State { } Future _load() async { - final items = await widget.store.conversations(); + var items = await widget.store.conversations(); + final blocked = await widget.settings?.blockedPubkeys(); + if (blocked != null && blocked.isNotEmpty) { + items = items.where((c) => !blocked.contains(c.peerPubkey)).toList(); + } final cache = widget.profileCache; if (cache != null) { for (final c in items) { diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 5cf249a..92ffd6b 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -16,6 +16,7 @@ import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; +import '../services/social_settings.dart'; import '../services/unread_service.dart'; import '../state/messages_cubit.dart'; import '../state/peer_rating_cubit.dart'; @@ -36,6 +37,7 @@ class ChatScreen extends StatefulWidget { this.messageStore, this.profileCache, this.onboarding, + this.settings, super.key, }); @@ -57,6 +59,11 @@ class ChatScreen extends StatefulWidget { /// entering the market). Null in tests → no gate. final OnboardingStore? onboarding; + /// Social settings holding the local blocklist; falls back to the app-wide + /// instance so the block action also works when not injected. Null in + /// tests → the block menu is hidden. + final SocialSettings? settings; + @override State createState() => _ChatScreenState(); } @@ -191,6 +198,42 @@ class _ChatScreenState extends State { } } + SocialSettings? get _settings => + widget.settings ?? + (getIt.isRegistered() ? getIt() : null); + + /// Blocks this peer after confirmation and leaves the chat; their offers, + /// conversation and future messages disappear (all locally). + Future _blockPeer() async { + final settings = _settings; + if (settings == null) return; + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.block.confirmTitle), + content: Text(t.block.confirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.block.confirm), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await settings.block(widget.peerPubkey); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.block.blockedToast))); + Navigator.of(context).pop(); + } + Future _send() async { final cubit = _messages; if (cubit == null || _input.text.trim().isEmpty) return; @@ -244,6 +287,19 @@ 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) => [ + 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 9cacebe..7f0058b 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -5,11 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import '../di/injector.dart'; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart'; import '../services/saved_offers_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; +import '../services/social_settings.dart'; import '../state/peer_rating_cubit.dart'; import 'market_widgets.dart'; import 'peer_avatar.dart'; @@ -29,6 +31,7 @@ class MarketOfferDetailScreen extends StatefulWidget { this.profileCache, this.selfPubkey, this.savedOffers, + this.settings, super.key, }); @@ -51,6 +54,10 @@ class MarketOfferDetailScreen extends StatefulWidget { /// null in tests (circle weighting then stays off). final String? selfPubkey; + /// Social settings holding the local blocklist; falls back to the app-wide + /// instance. Null in tests → the block action is hidden. + final SocialSettings? settings; + @override State createState() => _MarketOfferDetailScreenState(); @@ -161,6 +168,42 @@ class _MarketOfferDetailScreenState extends State { // No dispose of the session — it belongs to the shared connection. + SocialSettings? get _settings => + widget.settings ?? + (getIt.isRegistered() ? getIt() : null); + + /// Blocks the offer's author after confirmation and goes back to the market + /// (which reloads the blocklist and drops their offers). + Future _blockAuthor() async { + final settings = _settings; + if (settings == null) return; + final t = context.t; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.block.confirmTitle), + content: Text(t.block.confirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(t.block.confirm), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await settings.block(widget.offer.authorPubkeyHex); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(t.block.blockedToast))); + if (context.canPop()) context.pop(); + } + Future _copyId() async { final t = context.t; final messenger = ScaffoldMessenger.of(context); @@ -188,6 +231,19 @@ class _MarketOfferDetailScreenState extends State { tooltip: saved ? t.favorites.remove : t.favorites.save, onPressed: _toggleSaved, ), + if (!widget.mine && _settings != null) + PopupMenuButton( + key: const Key('offer.menu'), + onSelected: (value) { + if (value == 'block') _blockAuthor(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'block', + child: Text(t.block.action), + ), + ], + ), ], ), body: ListView( diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index e69c5ea..439231c 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -13,6 +13,7 @@ import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; +import 'blocked_people.dart'; import 'edge_fade.dart'; import 'filter_chips.dart'; import 'market_gate.dart'; @@ -92,6 +93,7 @@ class _MarketScreenState extends State { setState(() => _loading = true); final cubit = await createOffersCubit(widget.connection, repository: repo); + cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); @@ -123,6 +125,12 @@ 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. + Future _reloadBlocked() async { + _cubit?.setBlockedAuthors(await widget.settings.blockedPubkeys()); + } + Future _openConfig() async { final changed = await showModalBottomSheet( context: context, @@ -227,6 +235,7 @@ class _MarketScreenState extends State { onRetry: _init, hasArea: _hasArea, selfPubkey: widget.social.publicKeyHex, + onOfferClosed: _reloadBlocked, ), ), ); @@ -239,6 +248,7 @@ class MarketBody extends StatelessWidget { this.onRetry, this.hasArea = true, this.selfPubkey, + this.onOfferClosed, super.key, }); @@ -253,6 +263,10 @@ class MarketBody extends StatelessWidget { /// This user's own pubkey, so we don't offer to message our own listings. final String? selfPubkey; + /// Called after coming back from an offer's detail (which can block its + /// author), so the visible list refreshes against the blocklist. + final Future Function()? onOfferClosed; + @override Widget build(BuildContext context) { final t = context.t; @@ -371,8 +385,10 @@ class MarketBody extends StatelessWidget { return _OfferCard( offer: o, mine: mine, - onTap: () => - context.push('/market/offer', extra: o), + onTap: () async { + await context.push('/market/offer', extra: o); + await onOfferClosed?.call(); + }, ); }, ), @@ -826,6 +842,19 @@ class _ConfigSheetState extends State<_ConfigSheet> { helperMaxLines: 2, ), ), + ListTile( + key: const Key('market.blockedPeople'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.block, color: seedMuted), + title: Text(t.block.manageTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => + BlockedPeopleSheet(settings: widget.settings), + ), + ), ], ), ), diff --git a/apps/app_seeds/test/services/inbox_service_test.dart b/apps/app_seeds/test/services/inbox_service_test.dart index e4e271f..ad12f8e 100644 --- a/apps/app_seeds/test/services/inbox_service_test.dart +++ b/apps/app_seeds/test/services/inbox_service_test.dart @@ -61,6 +61,23 @@ void main() { text: text, at: DateTime.fromMillisecondsSinceEpoch(atMs)); + test('messages from a blocked peer are dropped before storage', () async { + final settings = SocialSettings(InMemorySecretStore()); + await settings.block('mallory'); + inbox = InboxService( + connection: await offlineConnection(), + selfPubkey: 'me', + store: store, + settings: settings, + ); + + await inbox.ingest(msg('mallory', 'spam', 1000)); + await inbox.ingest(msg('alice', 'hola', 2000)); + + final convos = await store.conversations(); + expect(convos.single.peerPubkey, 'alice'); // mallory never landed + }); + test('an incoming message is persisted into its conversation', () async { await inbox.ingest(msg('alice', 'got seeds?', 1000)); final convos = await store.conversations(); diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index e8c8673..6376e91 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -71,4 +71,25 @@ void main() { await store.write('tane.social.search_precision', 'oops'); expect(await SocialSettings(store).searchPrecision(), 4); }); + + test('the blocklist starts empty and round-trips block/unblock', () async { + expect(await settings.blockedPubkeys(), isEmpty); + + await settings.block('AB' * 32); + expect(await settings.isBlocked('ab' * 32), isTrue); // normalised + expect(await settings.blockedPubkeys(), {'ab' * 32}); + + await settings.block('cd' * 32); + expect(await settings.blockedPubkeys(), hasLength(2)); + + await settings.unblock('ab' * 32); + expect(await settings.isBlocked('ab' * 32), isFalse); + expect(await settings.blockedPubkeys(), {'cd' * 32}); + }); + + test('blocking twice keeps a single entry', () async { + await settings.block('ab' * 32); + await settings.block(' AB' * 1 + 'ab' * 31); // messy spacing/case + expect(await settings.blockedPubkeys(), hasLength(1)); + }); } diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 8542008..64ddc1a 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -184,6 +184,42 @@ void main() { await cubit.close(); }); + test('blocked authors disappear from the visible list and can resurface', + () async { + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-good', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-spam', + authorPubkeyHex: 'cd' * 32, + summary: 'Spam total', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.visibleOffers, hasLength(2)); + + cubit.setBlockedAuthors({'cd' * 32}); + expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); + expect(cubit.state.offers, hasLength(2)); // discoveries kept underneath + + // The blocklist survives a refresh (re-discovery resets results only). + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.visibleOffers.map((o) => o.summary), ['Tomate rosa']); + + cubit.setBlockedAuthors(const {}); // unblock resurfaces + expect(cubit.state.visibleOffers, hasLength(2)); + await cubit.close(); + }); + test('the search filter survives a refresh (re-discovery)', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot(