feat(social): local blocklist — hide blocked authors' offers, chats and incoming messages, with unblock management

This commit is contained in:
vjrj 2026-07-13 01:03:10 +02:00
parent 5d25f65f76
commit 11b242edbf
13 changed files with 399 additions and 4 deletions

View file

@ -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<void>.broadcast();
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<PrivateMessage>? _inboxSub;
@ -86,6 +93,10 @@ class InboxService {
/// testable seam (no relay).
@visibleForTesting
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);
if (!stored) return; // a re-delivered duplicate
if (!_changes.isClosed) _changes.add(null);

View file

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