tane/apps/app_seeds/lib/services/social_settings.dart

134 lines
5.5 KiB
Dart

import '../security/secret_store.dart';
/// User choices for the (Block 2) social layer: the coarse area to publish/
/// browse offers in, and which community relays to talk to. Backed by the OS
/// keystore (via [SecretStore]) to honour "no plaintext at rest" — no
/// shared_preferences.
///
/// Relays default to a small set of well-known public servers so the market
/// works out of the box; the exposure is minimal (offers are opt-in and carry
/// only a coarse geohash) and the user can swap them for a community server.
/// The area stays unset until the user picks one (it's inherently personal).
class SocialSettings {
SocialSettings(this._store);
final SecretStore _store;
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';
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").
/// Publishing always emits the full prefix ladder, so a coarser search still
/// matches finer offers — the default is deliberately wide so a still-sparse
/// network shows something.
static const int minSearchPrecision = 3;
static const int maxSearchPrecision = 5;
static const int defaultSearchPrecision = 4;
/// Community servers used automatically so sharing works from the first
/// launch. The relay pool skips any that are unreachable, so a dead one never
/// breaks the market; the user never has to know these exist. The Comunes
/// relay comes first as the reliable, non-commercial home; the public ones
/// are backup.
static const List<String> defaultRelays = [
'wss://relay.comunes.org',
'wss://nos.lol',
'wss://relay.damus.io',
'wss://relay.primal.net',
];
/// The user's coarse area as a low-precision geohash (may be empty). Set
/// manually or from device location; the transport coarsens it further.
Future<String> areaGeohash() async => (await _store.read(_areaKey)) ?? '';
Future<void> setAreaGeohash(String geohash) =>
_store.write(_areaKey, geohash.trim().toLowerCase());
/// The relays to talk to. When the user has never configured any, falls back
/// to [defaultRelays]. Once they save a choice (even an empty one), that wins
/// — so a privacy-minded user can turn the network off entirely.
Future<List<String>> relayUrls() async {
final raw = await _store.read(_relaysKey);
if (raw == null) return const [...defaultRelays]; // never configured
return raw.split('\n').where((s) => s.trim().isNotEmpty).toList();
}
Future<void> setRelayUrls(List<String> urls) => _store.write(
_relaysKey,
urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'),
);
/// How wide to search — a geohash prefix length in [minSearchPrecision,
/// maxSearchPrecision]. Defaults (and falls back on any garbage) to
/// [defaultSearchPrecision].
Future<int> searchPrecision() async {
final raw = await _store.read(_searchPrecisionKey);
final value = int.tryParse(raw ?? '');
if (value == null) return defaultSearchPrecision;
return _clampPrecision(value);
}
Future<void> setSearchPrecision(int precision) =>
_store.write(_searchPrecisionKey, '${_clampPrecision(precision)}');
int _clampPrecision(int p) => p < minSearchPrecision
? minSearchPrecision
: (p > maxSearchPrecision ? maxSearchPrecision : p);
/// 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'));
}
/// 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'));
}
}