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:
parent
62123582f5
commit
fed0e8200e
35 changed files with 926 additions and 173 deletions
46
apps/app_seeds/lib/services/sharing_switch.dart
Normal file
46
apps/app_seeds/lib/services/sharing_switch.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'social_connection.dart';
|
||||
import 'social_settings.dart';
|
||||
|
||||
/// The one place that turns the sharing side of Tane on and off.
|
||||
///
|
||||
/// Sharing is opt-in: until someone joins it, the app opens no connection at
|
||||
/// all and the seed book is entirely offline. Joining has to move three things
|
||||
/// at once — the stored choice, the live relay connection, and the flag the UI
|
||||
/// listens to — and doing that from several screens is how they drift apart.
|
||||
/// So every caller (the community-rules gate, the invite sheet, the sharing
|
||||
/// setup) goes through here instead.
|
||||
class SharingSwitch {
|
||||
SharingSwitch({
|
||||
required SocialSettings settings,
|
||||
SocialConnection? connection,
|
||||
bool enabled = false,
|
||||
}) : _settings = settings,
|
||||
_connection = connection,
|
||||
on = ValueNotifier(enabled);
|
||||
|
||||
final SocialSettings _settings;
|
||||
final SocialConnection? _connection;
|
||||
|
||||
/// Whether sharing is on right now. Screens listen so the drawer's social
|
||||
/// entries light up the moment someone joins, with no restart.
|
||||
final ValueNotifier<bool> on;
|
||||
|
||||
Future<void> enable() async {
|
||||
await _settings.setSharingEnabled(true);
|
||||
// Safe to call even if it is already running: `start` guards itself.
|
||||
_connection?.start();
|
||||
on.value = true;
|
||||
}
|
||||
|
||||
/// Turning it off must actually go offline — closing the live session, not
|
||||
/// just recording the choice for the next launch.
|
||||
Future<void> disable() async {
|
||||
await _settings.setSharingEnabled(false);
|
||||
await _connection?.stop();
|
||||
on.value = false;
|
||||
}
|
||||
|
||||
void dispose() => on.dispose();
|
||||
}
|
||||
|
|
@ -63,8 +63,11 @@ class SocialConnection {
|
|||
SocialSession? get current => _current;
|
||||
|
||||
/// Begins watching connectivity (reconnect on regain, drop when offline) and
|
||||
/// attempts an initial connect. Idempotent-ish; call once at startup.
|
||||
/// attempts an initial connect. Called at startup when sharing is already on,
|
||||
/// and again the moment someone joins the sharing side — hence the guard, so
|
||||
/// a second call never stacks a second connectivity subscription.
|
||||
void start() {
|
||||
if (_started || _disposed) return;
|
||||
_started = true;
|
||||
_onlineSub = (_online ?? _connectivityOnline()).listen((isOnline) {
|
||||
_knownOffline = !isOnline;
|
||||
|
|
@ -139,6 +142,19 @@ class SocialConnection {
|
|||
}
|
||||
}
|
||||
|
||||
/// Goes offline for good until [start] is called again: stops watching
|
||||
/// connectivity, cancels any pending retry and tears the live session down.
|
||||
/// This is what "turn sharing off" must do — before it existed, clearing the
|
||||
/// server list only took effect on the next launch, because the already-open
|
||||
/// session was never closed. Unlike [dispose] the object stays usable.
|
||||
Future<void> stop() async {
|
||||
_started = false;
|
||||
_cancelRetry();
|
||||
await _onlineSub?.cancel();
|
||||
_onlineSub = null;
|
||||
_drop();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true;
|
||||
_cancelRetry();
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue