feat(social): local blocklist — hide blocked authors' offers, chats and incoming messages, with unblock management
This commit is contained in:
parent
5d25f65f76
commit
11b242edbf
13 changed files with 399 additions and 4 deletions
|
|
@ -184,6 +184,7 @@ class TaneApp extends StatelessWidget {
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
selfPubkey: social.publicKeyHex,
|
selfPubkey: social.publicKeyHex,
|
||||||
savedOffers: savedOffers,
|
savedOffers: savedOffers,
|
||||||
|
settings: socialSettings,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -203,6 +204,7 @@ class TaneApp extends StatelessWidget {
|
||||||
connection: connection,
|
connection: connection,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
|
settings: socialSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null &&
|
if (social != null &&
|
||||||
|
|
@ -238,6 +240,7 @@ class TaneApp extends StatelessWidget {
|
||||||
messageStore: messageStore,
|
messageStore: messageStore,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
onboarding: onboarding,
|
onboarding: onboarding,
|
||||||
|
settings: socialSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ Future<void> configureDependencies() async {
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
unread: getIt<UnreadService>(),
|
unread: getIt<UnreadService>(),
|
||||||
notifications: getIt<NotificationService>(),
|
notifications: getIt<NotificationService>(),
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Replicates the inventory across this identity's own devices over the
|
// Replicates the inventory across this identity's own devices over the
|
||||||
|
|
@ -342,6 +343,7 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
unread: getIt<UnreadService>(),
|
unread: getIt<UnreadService>(),
|
||||||
notifications: getIt<NotificationService>(),
|
notifications: getIt<NotificationService>(),
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
);
|
);
|
||||||
final sync = SyncService(
|
final sync = SyncService(
|
||||||
connection: connection,
|
connection: connection,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'notification_service.dart';
|
||||||
import 'profile_cache.dart';
|
import 'profile_cache.dart';
|
||||||
import 'social_connection.dart';
|
import 'social_connection.dart';
|
||||||
import 'social_service.dart' show SocialSession;
|
import 'social_service.dart' show SocialSession;
|
||||||
|
import 'social_settings.dart';
|
||||||
import 'unread_service.dart';
|
import 'unread_service.dart';
|
||||||
|
|
||||||
/// App-wide inbox listener for private messages (NIP-17).
|
/// App-wide inbox listener for private messages (NIP-17).
|
||||||
|
|
@ -31,12 +32,14 @@ class InboxService {
|
||||||
ProfileCache? profileCache,
|
ProfileCache? profileCache,
|
||||||
UnreadService? unread,
|
UnreadService? unread,
|
||||||
NotificationService? notifications,
|
NotificationService? notifications,
|
||||||
|
SocialSettings? settings,
|
||||||
}) : _connection = connection,
|
}) : _connection = connection,
|
||||||
_selfPubkey = selfPubkey,
|
_selfPubkey = selfPubkey,
|
||||||
_store = store,
|
_store = store,
|
||||||
_profileCache = profileCache,
|
_profileCache = profileCache,
|
||||||
_unread = unread,
|
_unread = unread,
|
||||||
_notifications = notifications;
|
_notifications = notifications,
|
||||||
|
_settings = settings;
|
||||||
|
|
||||||
final SocialConnection _connection;
|
final SocialConnection _connection;
|
||||||
final String _selfPubkey;
|
final String _selfPubkey;
|
||||||
|
|
@ -45,6 +48,10 @@ class InboxService {
|
||||||
final UnreadService? _unread;
|
final UnreadService? _unread;
|
||||||
final NotificationService? _notifications;
|
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<void>.broadcast();
|
final _changes = StreamController<void>.broadcast();
|
||||||
StreamSubscription<SocialSession?>? _sessionsSub;
|
StreamSubscription<SocialSession?>? _sessionsSub;
|
||||||
StreamSubscription<PrivateMessage>? _inboxSub;
|
StreamSubscription<PrivateMessage>? _inboxSub;
|
||||||
|
|
@ -86,6 +93,10 @@ class InboxService {
|
||||||
/// testable seam (no relay).
|
/// testable seam (no relay).
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<void> ingest(PrivateMessage message) async {
|
Future<void> 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);
|
final stored = await _store.append(message.fromPubkey, message);
|
||||||
if (!stored) return; // a re-delivered duplicate
|
if (!stored) return; // a re-delivered duplicate
|
||||||
if (!_changes.isClosed) _changes.add(null);
|
if (!_changes.isClosed) _changes.add(null);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class SocialSettings {
|
||||||
static const _areaKey = 'tane.social.area_geohash';
|
static const _areaKey = 'tane.social.area_geohash';
|
||||||
static const _relaysKey = 'tane.social.relays';
|
static const _relaysKey = 'tane.social.relays';
|
||||||
static const _searchPrecisionKey = 'tane.social.search_precision';
|
static const _searchPrecisionKey = 'tane.social.search_precision';
|
||||||
|
static const _blockedKey = 'tane.social.blocked_pubkeys';
|
||||||
|
|
||||||
/// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km
|
/// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km
|
||||||
/// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region").
|
/// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region").
|
||||||
|
|
@ -80,4 +81,32 @@ class SocialSettings {
|
||||||
/// Whether the social layer has enough config to attempt going online.
|
/// Whether the social layer has enough config to attempt going online.
|
||||||
Future<bool> get isConfigured async =>
|
Future<bool> get isConfigured async =>
|
||||||
(await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty;
|
(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<Set<String>> blockedPubkeys() async {
|
||||||
|
final raw = await _store.read(_blockedKey);
|
||||||
|
if (raw == null) return <String>{};
|
||||||
|
return raw
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isBlocked(String pubkeyHex) async =>
|
||||||
|
(await blockedPubkeys()).contains(pubkeyHex.trim().toLowerCase());
|
||||||
|
|
||||||
|
Future<void> block(String pubkeyHex) async {
|
||||||
|
final set = await blockedPubkeys()
|
||||||
|
..add(pubkeyHex.trim().toLowerCase());
|
||||||
|
await _store.write(_blockedKey, set.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unblock(String pubkeyHex) async {
|
||||||
|
final set = await blockedPubkeys()
|
||||||
|
..remove(pubkeyHex.trim().toLowerCase());
|
||||||
|
await _store.write(_blockedKey, set.join('\n'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ class OffersState extends Equatable {
|
||||||
this.typeFilter = const {},
|
this.typeFilter = const {},
|
||||||
this.categoryFilter = const {},
|
this.categoryFilter = const {},
|
||||||
this.organicOnly = false,
|
this.organicOnly = false,
|
||||||
|
this.blockedAuthors = const {},
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = false,
|
this.hasSearched = false,
|
||||||
|
|
@ -45,6 +46,11 @@ class OffersState extends Equatable {
|
||||||
/// When true, show only offers the grower declared organic ("eco").
|
/// When true, show only offers the grower declared organic ("eco").
|
||||||
final bool organicOnly;
|
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<String> blockedAuthors;
|
||||||
|
|
||||||
final bool searching;
|
final bool searching;
|
||||||
final bool publishing;
|
final bool publishing;
|
||||||
|
|
||||||
|
|
@ -60,6 +66,7 @@ class OffersState extends Equatable {
|
||||||
List<Offer> get visibleOffers {
|
List<Offer> get visibleOffers {
|
||||||
final q = query.trim().toLowerCase();
|
final q = query.trim().toLowerCase();
|
||||||
return offers.where((o) {
|
return offers.where((o) {
|
||||||
|
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
||||||
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
||||||
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
||||||
if (categoryFilter.isNotEmpty &&
|
if (categoryFilter.isNotEmpty &&
|
||||||
|
|
@ -96,6 +103,7 @@ class OffersState extends Equatable {
|
||||||
Set<OfferType>? typeFilter,
|
Set<OfferType>? typeFilter,
|
||||||
Set<String>? categoryFilter,
|
Set<String>? categoryFilter,
|
||||||
bool? organicOnly,
|
bool? organicOnly,
|
||||||
|
Set<String>? blockedAuthors,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
|
|
@ -108,6 +116,7 @@ class OffersState extends Equatable {
|
||||||
typeFilter: typeFilter ?? this.typeFilter,
|
typeFilter: typeFilter ?? this.typeFilter,
|
||||||
categoryFilter: categoryFilter ?? this.categoryFilter,
|
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||||
organicOnly: organicOnly ?? this.organicOnly,
|
organicOnly: organicOnly ?? this.organicOnly,
|
||||||
|
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
|
|
@ -123,6 +132,7 @@ class OffersState extends Equatable {
|
||||||
typeFilter,
|
typeFilter,
|
||||||
categoryFilter,
|
categoryFilter,
|
||||||
organicOnly,
|
organicOnly,
|
||||||
|
blockedAuthors,
|
||||||
searching,
|
searching,
|
||||||
publishing,
|
publishing,
|
||||||
hasSearched,
|
hasSearched,
|
||||||
|
|
@ -180,6 +190,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
typeFilter: state.typeFilter,
|
typeFilter: state.typeFilter,
|
||||||
categoryFilter: state.categoryFilter,
|
categoryFilter: state.categoryFilter,
|
||||||
organicOnly: state.organicOnly,
|
organicOnly: state.organicOnly,
|
||||||
|
blockedAuthors: state.blockedAuthors,
|
||||||
searching: true,
|
searching: true,
|
||||||
hasSearched: true,
|
hasSearched: true,
|
||||||
));
|
));
|
||||||
|
|
@ -234,6 +245,11 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
void toggleOrganicOnly() =>
|
void toggleOrganicOnly() =>
|
||||||
emit(state.copyWith(organicOnly: !state.organicOnly));
|
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<String> pubkeys) =>
|
||||||
|
emit(state.copyWith(blockedAuthors: pubkeys));
|
||||||
|
|
||||||
/// Clears every chip filter (leaves the text search untouched).
|
/// Clears every chip filter (leaves the text search untouched).
|
||||||
void clearFilters() => emit(state.copyWith(
|
void clearFilters() => emit(state.copyWith(
|
||||||
typeFilter: const {},
|
typeFilter: const {},
|
||||||
|
|
|
||||||
109
apps/app_seeds/lib/ui/blocked_people.dart
Normal file
109
apps/app_seeds/lib/ui/blocked_people.dart
Normal file
|
|
@ -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<BlockedPeopleSheet> createState() => _BlockedPeopleSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlockedPeopleSheetState extends State<BlockedPeopleSheet> {
|
||||||
|
List<String>? _blocked;
|
||||||
|
final Map<String, String> _names = {};
|
||||||
|
|
||||||
|
ProfileCache? get _cache =>
|
||||||
|
widget.profileCache ??
|
||||||
|
(getIt.isRegistered<ProfileCache>() ? getIt<ProfileCache>() : null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import 'peer_avatar.dart';
|
import 'peer_avatar.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
|
import '../services/social_settings.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'unread_badge.dart';
|
import 'unread_badge.dart';
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ class ChatListScreen extends StatefulWidget {
|
||||||
this.connection,
|
this.connection,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.inbox,
|
this.inbox,
|
||||||
|
this.settings,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -32,6 +34,10 @@ class ChatListScreen extends StatefulWidget {
|
||||||
/// App-wide inbox listener; the list reloads whenever it reports a change.
|
/// App-wide inbox listener; the list reloads whenever it reports a change.
|
||||||
final InboxService? inbox;
|
final InboxService? inbox;
|
||||||
|
|
||||||
|
/// Social settings holding the local blocklist; blocked peers' conversations
|
||||||
|
/// are hidden. Null in tests → nothing filtered.
|
||||||
|
final SocialSettings? settings;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +62,11 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _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;
|
final cache = widget.profileCache;
|
||||||
if (cache != null) {
|
if (cache != null) {
|
||||||
for (final c in items) {
|
for (final c in items) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../services/profile_cache.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
|
import '../services/social_settings.dart';
|
||||||
import '../services/unread_service.dart';
|
import '../services/unread_service.dart';
|
||||||
import '../state/messages_cubit.dart';
|
import '../state/messages_cubit.dart';
|
||||||
import '../state/peer_rating_cubit.dart';
|
import '../state/peer_rating_cubit.dart';
|
||||||
|
|
@ -36,6 +37,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
this.messageStore,
|
this.messageStore,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.onboarding,
|
this.onboarding,
|
||||||
|
this.settings,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,6 +59,11 @@ class ChatScreen extends StatefulWidget {
|
||||||
/// entering the market). Null in tests → no gate.
|
/// entering the market). Null in tests → no gate.
|
||||||
final OnboardingStore? onboarding;
|
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
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +198,42 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SocialSettings? get _settings =>
|
||||||
|
widget.settings ??
|
||||||
|
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
||||||
|
|
||||||
|
/// Blocks this peer after confirmation and leaves the chat; their offers,
|
||||||
|
/// conversation and future messages disappear (all locally).
|
||||||
|
Future<void> _blockPeer() async {
|
||||||
|
final settings = _settings;
|
||||||
|
if (settings == null) return;
|
||||||
|
final t = context.t;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _send() async {
|
Future<void> _send() async {
|
||||||
final cubit = _messages;
|
final cubit = _messages;
|
||||||
if (cubit == null || _input.text.trim().isEmpty) return;
|
if (cubit == null || _input.text.trim().isEmpty) return;
|
||||||
|
|
@ -244,6 +287,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
tooltip: t.chat.payG1,
|
tooltip: t.chat.payG1,
|
||||||
onPressed: _payG1,
|
onPressed: _payG1,
|
||||||
),
|
),
|
||||||
|
if (_settings != null)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
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
|
body: _loading || messages == null || trust == null || rating == null
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/saved_offers_store.dart';
|
import '../services/saved_offers_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
|
import '../services/social_settings.dart';
|
||||||
import '../state/peer_rating_cubit.dart';
|
import '../state/peer_rating_cubit.dart';
|
||||||
import 'market_widgets.dart';
|
import 'market_widgets.dart';
|
||||||
import 'peer_avatar.dart';
|
import 'peer_avatar.dart';
|
||||||
|
|
@ -29,6 +31,7 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.selfPubkey,
|
this.selfPubkey,
|
||||||
this.savedOffers,
|
this.savedOffers,
|
||||||
|
this.settings,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,6 +54,10 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
/// null in tests (circle weighting then stays off).
|
/// null in tests (circle weighting then stays off).
|
||||||
final String? selfPubkey;
|
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
|
@override
|
||||||
State<MarketOfferDetailScreen> createState() =>
|
State<MarketOfferDetailScreen> createState() =>
|
||||||
_MarketOfferDetailScreenState();
|
_MarketOfferDetailScreenState();
|
||||||
|
|
@ -161,6 +168,42 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
|
|
||||||
// No dispose of the session — it belongs to the shared connection.
|
// No dispose of the session — it belongs to the shared connection.
|
||||||
|
|
||||||
|
SocialSettings? get _settings =>
|
||||||
|
widget.settings ??
|
||||||
|
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
final settings = _settings;
|
||||||
|
if (settings == null) return;
|
||||||
|
final t = context.t;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _copyId() async {
|
Future<void> _copyId() async {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
@ -188,6 +231,19 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
||||||
onPressed: _toggleSaved,
|
onPressed: _toggleSaved,
|
||||||
),
|
),
|
||||||
|
if (!widget.mine && _settings != null)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
key: const Key('offer.menu'),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'block') _blockAuthor();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'block',
|
||||||
|
child: Text(t.block.action),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
import '../state/offers_cubit.dart';
|
import '../state/offers_cubit.dart';
|
||||||
|
import 'blocked_people.dart';
|
||||||
import 'edge_fade.dart';
|
import 'edge_fade.dart';
|
||||||
import 'filter_chips.dart';
|
import 'filter_chips.dart';
|
||||||
import 'market_gate.dart';
|
import 'market_gate.dart';
|
||||||
|
|
@ -92,6 +93,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final cubit =
|
final cubit =
|
||||||
await createOffersCubit(widget.connection, repository: repo);
|
await createOffersCubit(widget.connection, repository: repo);
|
||||||
|
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||||
final area = await widget.settings.areaGeohash();
|
final area = await widget.settings.areaGeohash();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
|
|
@ -123,6 +125,12 @@ 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.
|
||||||
|
Future<void> _reloadBlocked() async {
|
||||||
|
_cubit?.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openConfig() async {
|
Future<void> _openConfig() async {
|
||||||
final changed = await showModalBottomSheet<bool>(
|
final changed = await showModalBottomSheet<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -227,6 +235,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
onRetry: _init,
|
onRetry: _init,
|
||||||
hasArea: _hasArea,
|
hasArea: _hasArea,
|
||||||
selfPubkey: widget.social.publicKeyHex,
|
selfPubkey: widget.social.publicKeyHex,
|
||||||
|
onOfferClosed: _reloadBlocked,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -239,6 +248,7 @@ class MarketBody extends StatelessWidget {
|
||||||
this.onRetry,
|
this.onRetry,
|
||||||
this.hasArea = true,
|
this.hasArea = true,
|
||||||
this.selfPubkey,
|
this.selfPubkey,
|
||||||
|
this.onOfferClosed,
|
||||||
super.key,
|
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.
|
/// This user's own pubkey, so we don't offer to message our own listings.
|
||||||
final String? selfPubkey;
|
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<void> Function()? onOfferClosed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -371,8 +385,10 @@ class MarketBody extends StatelessWidget {
|
||||||
return _OfferCard(
|
return _OfferCard(
|
||||||
offer: o,
|
offer: o,
|
||||||
mine: mine,
|
mine: mine,
|
||||||
onTap: () =>
|
onTap: () async {
|
||||||
context.push('/market/offer', extra: o),
|
await context.push('/market/offer', extra: o);
|
||||||
|
await onOfferClosed?.call();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -826,6 +842,19 @@ class _ConfigSheetState extends State<_ConfigSheet> {
|
||||||
helperMaxLines: 2,
|
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<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) =>
|
||||||
|
BlockedPeopleSheet(settings: widget.settings),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,23 @@ void main() {
|
||||||
text: text,
|
text: text,
|
||||||
at: DateTime.fromMillisecondsSinceEpoch(atMs));
|
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 {
|
test('an incoming message is persisted into its conversation', () async {
|
||||||
await inbox.ingest(msg('alice', 'got seeds?', 1000));
|
await inbox.ingest(msg('alice', 'got seeds?', 1000));
|
||||||
final convos = await store.conversations();
|
final convos = await store.conversations();
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,25 @@ void main() {
|
||||||
await store.write('tane.social.search_precision', 'oops');
|
await store.write('tane.social.search_precision', 'oops');
|
||||||
expect(await SocialSettings(store).searchPrecision(), 4);
|
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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,42 @@ void main() {
|
||||||
await cubit.close();
|
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 {
|
test('the search filter survives a refresh (re-discovery)', () async {
|
||||||
final transport = FakeOfferTransport();
|
final transport = FakeOfferTransport();
|
||||||
await transport.publish(OfferMapper.fromSharedLot(
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue