feat(sharing): make going online opt-in, and show what it unlocks

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).
This commit is contained in:
vjrj 2026-07-25 16:47:56 +02:00
parent 62123582f5
commit fed0e8200e
35 changed files with 926 additions and 173 deletions

View file

@ -5,9 +5,11 @@ import '../security/secret_store.dart';
/// 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.
/// 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);
@ -16,6 +18,7 @@ class SocialSettings {
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';
@ -29,11 +32,11 @@ class SocialSettings {
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.
/// 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',
@ -62,6 +65,38 @@ class SocialSettings {
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].