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. /// /// Sharing is off until the person joins it ([sharingEnabled]); until then the /// app opens no connection at all. Once they do, 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 or turn them all off. /// 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 _sharingKey = 'tane.social.sharing_enabled'; 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 once the person joins the sharing /// side, so the market works without any setup. 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 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 areaGeohash() async => (await _store.read(_areaKey)) ?? ''; Future 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> 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 setRelayUrls(List urls) => _store.write( _relaysKey, urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), ); /// Whether the person has said yes to the sharing side of the app. Until they /// do, Tane opens no connection at all — the seed book is entirely offline. /// /// Three states on purpose: `null` means "never asked", which is what lets an /// install that predates this setting keep working exactly as before (see /// `migrateSharingEnabled`). Once written it is a plain yes/no the person /// controls from the sharing setup. Future sharingEnabled() async { final raw = await _store.read(_sharingKey); if (raw == null) return null; return raw == '1'; } Future setSharingEnabled(bool enabled) => _store.write(_sharingKey, enabled ? '1' : '0'); /// Decides, once, what an install that predates the setting should get, and /// records it. Anyone who had already been through the intro was on a build /// that connected at launch, so they keep sharing on and lose nothing — /// messages, device sync and offer alerts keep arriving. A fresh install has /// not seen the intro yet, so it starts fully offline and only goes online /// when the person joins the sharing side. /// /// Returns the effective value. Safe to call on every launch: it writes only /// when nothing has been recorded yet. Future migrateSharingEnabled({required bool introSeen}) async { final stored = await sharingEnabled(); if (stored != null) return stored; await setSharingEnabled(introSeen); return introSeen; } /// How wide to search — a geohash prefix length in [minSearchPrecision, /// maxSearchPrecision]. Defaults (and falls back on any garbage) to /// [defaultSearchPrecision]. Future searchPrecision() async { final raw = await _store.read(_searchPrecisionKey); final value = int.tryParse(raw ?? ''); if (value == null) return defaultSearchPrecision; return _clampPrecision(value); } Future 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 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')); } /// 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> hiddenOfferKeys() async { final raw = await _store.read(_hiddenOffersKey); if (raw == null) return {}; return raw.split('\n').where((s) => s.trim().isNotEmpty).toSet(); } Future hideOffer({ required String authorPubkeyHex, required String offerId, }) async { final set = await hiddenOfferKeys() ..add(offerKey(authorPubkeyHex, offerId)); await _store.write(_hiddenOffersKey, set.join('\n')); } }