Tane dialled its four default relays at launch, before anyone had asked for anything — an F-Droid reviewer spotted it, and they were right. The seed book needs no network at all, so the app should not have one until the person joins the sharing side. - SocialSettings gains a three-state `sharingEnabled`. `null` means "never asked", which is what lets `migrateSharingEnabled` keep an existing install exactly as it was: anyone past the intro was on a build that connected at launch, so they keep messaging, device sync and offer alerts. A fresh install starts fully offline. - bootstrap only starts the shared connection when sharing is on. The inbox/sync/plantaré/alert listeners are untouched: they react to a session, and none arrives. - SharingSwitch is the single place that moves the stored choice, the live connection and the flag the UI listens to, so they cannot drift. - Agreeing to the community rules is the opt-in — one consent surface, reached from the market or from the drawer's invitation. - SocialConnection.start is now idempotent and gains stop(), so turning sharing off goes offline immediately instead of at the next launch. - The social drawer entries stay visible but padlocked while sharing is off; tapping one explains what wakes up and offers to join. Hiding them would have kept the tool a secret. "Coming soon" is gone for good — everything it labelled is built. Covered by tests for the migration in both directions, start/stop lifecycle, the gate turning sharing on, the invitation, and the drawer in all three states (no social layer / off / on).
169 lines
7.2 KiB
Dart
169 lines
7.2 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.
|
|
///
|
|
/// 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<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'),
|
|
);
|
|
|
|
/// 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<bool?> sharingEnabled() async {
|
|
final raw = await _store.read(_sharingKey);
|
|
if (raw == null) return null;
|
|
return raw == '1';
|
|
}
|
|
|
|
Future<void> 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<bool> 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<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'));
|
|
}
|
|
}
|