feat(#168): BlockStore + BlockService (global block-list foundation)
App-global (NOT per-device) block list, source of truth for the client: - BlockStore persists blockedKeys (pubkey hex) + blockedNames (name->first-seen epoch, for A7 promote-and-prune) via PrefsManager, global keys. - BlockService (ChangeNotifier): isBlocked/isNameBlocked/block/unblock/ blockName/unblockName + unmodifiable accessors. - Wired into main.dart MultiProvider. Epic A #165 / A1 #168. Design: docs/architecture/block-contract-as-built.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/209/head
parent
dc20cc17f1
commit
d63f57a698
@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../storage/block_store.dart';
|
||||||
|
|
||||||
|
/// App-global block list, backed by [BlockStore] and exposed to the UI.
|
||||||
|
///
|
||||||
|
/// Source of truth for the client: DMs + adverts are filtered by public key,
|
||||||
|
/// channel posts by resolved key or claimed name. Always active regardless of
|
||||||
|
/// firmware; the firmware offload (Epic B) mirrors this, never replaces it.
|
||||||
|
/// See `docs/architecture/block-contract-as-built.md`.
|
||||||
|
class BlockService extends ChangeNotifier {
|
||||||
|
BlockService({BlockStore? store}) : _store = store ?? BlockStore();
|
||||||
|
|
||||||
|
final BlockStore _store;
|
||||||
|
|
||||||
|
final Set<String> _blockedKeys = {};
|
||||||
|
final Map<String, int> _blockedNames = {};
|
||||||
|
|
||||||
|
/// Load persisted state. Call once during app startup.
|
||||||
|
Future<void> load() async {
|
||||||
|
_blockedKeys
|
||||||
|
..clear()
|
||||||
|
..addAll(await _store.loadKeys());
|
||||||
|
_blockedNames
|
||||||
|
..clear()
|
||||||
|
..addAll(await _store.loadNames());
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> get blockedKeys => Set.unmodifiable(_blockedKeys);
|
||||||
|
Map<String, int> get blockedNames => Map.unmodifiable(_blockedNames);
|
||||||
|
|
||||||
|
bool isBlocked(String publicKeyHex) =>
|
||||||
|
_blockedKeys.contains(publicKeyHex.toLowerCase());
|
||||||
|
|
||||||
|
bool isNameBlocked(String name) =>
|
||||||
|
_blockedNames.containsKey(name.trim().toLowerCase());
|
||||||
|
|
||||||
|
Future<void> block(String publicKeyHex) async {
|
||||||
|
if (!_blockedKeys.add(publicKeyHex.toLowerCase())) return;
|
||||||
|
await _store.saveKeys(_blockedKeys);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unblock(String publicKeyHex) async {
|
||||||
|
if (!_blockedKeys.remove(publicKeyHex.toLowerCase())) return;
|
||||||
|
await _store.saveKeys(_blockedKeys);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> blockName(String name) async {
|
||||||
|
final n = name.trim().toLowerCase();
|
||||||
|
if (n.isEmpty || _blockedNames.containsKey(n)) return;
|
||||||
|
_blockedNames[n] = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await _store.saveNames(_blockedNames);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unblockName(String name) async {
|
||||||
|
if (_blockedNames.remove(name.trim().toLowerCase()) == null) return;
|
||||||
|
await _store.saveNames(_blockedNames);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import 'prefs_manager.dart';
|
||||||
|
|
||||||
|
/// Persistence for the app-global block list.
|
||||||
|
///
|
||||||
|
/// Deliberately **global** — unlike the other stores it is NOT scoped by the
|
||||||
|
/// connected device key. A block is "I don't want to see this person," keyed by
|
||||||
|
/// their public key, and must hold regardless of which radio is connected.
|
||||||
|
/// See `docs/architecture/block-contract-as-built.md`.
|
||||||
|
class BlockStore {
|
||||||
|
static const String _keysKey = 'block_keys_v1';
|
||||||
|
static const String _namesKey = 'block_names_v1';
|
||||||
|
|
||||||
|
/// Blocked public keys (lowercased hex).
|
||||||
|
Future<Set<String>> loadKeys() async {
|
||||||
|
final list = PrefsManager.instance.getStringList(_keysKey) ?? const [];
|
||||||
|
return list.map((k) => k.toLowerCase()).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveKeys(Set<String> keys) async {
|
||||||
|
await PrefsManager.instance.setStringList(_keysKey, keys.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name-only blocks: lowercased claimed name -> first-blocked epoch millis.
|
||||||
|
/// The timestamp feeds the promote-and-prune age-out (Epic A / A7).
|
||||||
|
Future<Map<String, int>> loadNames() async {
|
||||||
|
final raw = PrefsManager.instance.getString(_namesKey);
|
||||||
|
if (raw == null || raw.isEmpty) return {};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.error('Failed to decode blocked names: $e');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveNames(Map<String, int> names) async {
|
||||||
|
await PrefsManager.instance.setString(_namesKey, jsonEncode(names));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue