feat(block2): default relays + pick-from-list servers (works out of the box)

Feedback: the blank 'web address' field was unusable — nobody knows what to put,
so the market never worked at first launch.
- SocialSettings ships default public relays (nos.lol, relay.damus.io), applied
  automatically for new users so the market works day one. An explicitly empty
  choice still turns the network off (privacy). Exposure is minimal: offers are
  opt-in and carry only a coarse geohash.
- Sharing setup now shows the servers as a short PICK-LIST (checkboxes, pre-
  selected) instead of a blank field; a community's own server goes under a small
  'add another' escape hatch. Intro reworded — no need to know a community.
- Tests updated (defaults) and pinned offline (setRelayUrls([])) so widget tests
  don't hit the network. 22 green, analyzer clean.
This commit is contained in:
vjrj 2026-07-10 11:19:49 +02:00
parent a72224f95a
commit d369015e01
12 changed files with 131 additions and 66 deletions

View file

@ -344,12 +344,12 @@
"near": "Near you",
"contact": "Message",
"configTitle": "Sharing setup",
"setupIntro": "Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.",
"setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle. You can change the servers below.",
"areaLabel": "Your area",
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
"serversAdvanced": "Join a community (advanced)",
"serversHelp": "Your seed network or collective gives you a web address to connect to. Paste one per line. Leave empty if you don't have one yet.",
"serversLabel": "Web address",
"serversLabel": "Community servers",
"serversHelp": "Pick one or more shared servers. Anyone can use them — or add your own community's below.",
"serversAdvanced": "Add another server",
"save": "Save",
"saved": "Saved",
"wanted": "Wanted",

View file

@ -344,12 +344,12 @@
"near": "Cerca de ti",
"contact": "Mensaje",
"configTitle": "Configuración de compartir",
"setupIntro": "Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.",
"setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio. Puedes cambiar los servidores abajo.",
"areaLabel": "Tu zona",
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
"serversAdvanced": "Unirse a una comunidad (avanzado)",
"serversHelp": "Tu red de semillas o colectivo te da una dirección web para conectarte. Pega una por línea. Déjalo vacío si aún no tienes ninguna.",
"serversLabel": "Dirección web",
"serversLabel": "Servidores de la comunidad",
"serversHelp": "Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.",
"serversAdvanced": "Añadir otro servidor",
"save": "Guardar",
"saved": "Guardado",
"wanted": "Busco",

View file

@ -340,12 +340,12 @@
"near": "Perto de ti",
"contact": "Mensagem",
"configTitle": "Configuração de partilha",
"setupIntro": "Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.",
"setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio. Podes mudar os servidores abaixo.",
"areaLabel": "A tua zona",
"areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.",
"serversAdvanced": "Juntar-te a uma comunidade (avançado)",
"serversHelp": "A tua rede de sementes ou coletivo dá-te um endereço web para te ligares. Cola um por linha. Deixa vazio se ainda não tiveres nenhum.",
"serversLabel": "Endereço web",
"serversLabel": "Servidores da comunidade",
"serversHelp": "Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.",
"serversAdvanced": "Adicionar outro servidor",
"save": "Guardar",
"saved": "Guardado",
"wanted": "Procuro",

View file

@ -6,7 +6,7 @@
/// Locales: 3
/// Strings: 986 (328 per locale)
///
/// Built on 2026-07-10 at 09:08 UTC
/// Built on 2026-07-10 at 09:19 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -1093,8 +1093,8 @@ class Translations$market$en {
/// en: 'Sharing setup'
String get configTitle => 'Sharing setup';
/// en: 'Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.'
String get setupIntro => 'Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.';
/// en: 'Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle. You can change the servers below.'
String get setupIntro => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle. You can change the servers below.';
/// en: 'Your area'
String get areaLabel => 'Your area';
@ -1102,14 +1102,14 @@ class Translations$market$en {
/// en: 'Kept rough on purpose — your zone, never an exact spot.'
String get areaHelp => 'Kept rough on purpose — your zone, never an exact spot.';
/// en: 'Join a community (advanced)'
String get serversAdvanced => 'Join a community (advanced)';
/// en: 'Community servers'
String get serversLabel => 'Community servers';
/// en: 'Your seed network or collective gives you a web address to connect to. Paste one per line. Leave empty if you don't have one yet.'
String get serversHelp => 'Your seed network or collective gives you a web address to connect to. Paste one per line. Leave empty if you don\'t have one yet.';
/// en: 'Pick one or more shared servers. Anyone can use them — or add your own community's below.'
String get serversHelp => 'Pick one or more shared servers. Anyone can use them — or add your own community\'s below.';
/// en: 'Web address'
String get serversLabel => 'Web address';
/// en: 'Add another server'
String get serversAdvanced => 'Add another server';
/// en: 'Save'
String get save => 'Save';
@ -1947,12 +1947,12 @@ extension on Translations {
'market.near' => 'Near you',
'market.contact' => 'Message',
'market.configTitle' => 'Sharing setup',
'market.setupIntro' => 'Sharing with people nearby is optional. To turn it on, set your rough area and join a community you trust — a seed network or collective that runs a shared meeting point for passing messages. No company in the middle.',
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle. You can change the servers below.',
'market.areaLabel' => 'Your area',
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
'market.serversAdvanced' => 'Join a community (advanced)',
'market.serversHelp' => 'Your seed network or collective gives you a web address to connect to. Paste one per line. Leave empty if you don\'t have one yet.',
'market.serversLabel' => 'Web address',
'market.serversLabel' => 'Community servers',
'market.serversHelp' => 'Pick one or more shared servers. Anyone can use them — or add your own community\'s below.',
'market.serversAdvanced' => 'Add another server',
'market.save' => 'Save',
'market.saved' => 'Saved',
'market.wanted' => 'Wanted',

View file

@ -616,12 +616,12 @@ class _Translations$market$es extends Translations$market$en {
@override String get near => 'Cerca de ti';
@override String get contact => 'Mensaje';
@override String get configTitle => 'Configuración de compartir';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.';
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio. Puedes cambiar los servidores abajo.';
@override String get areaLabel => 'Tu zona';
@override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.';
@override String get serversAdvanced => 'Unirse a una comunidad (avanzado)';
@override String get serversHelp => 'Tu red de semillas o colectivo te da una dirección web para conectarte. Pega una por línea. Déjalo vacío si aún no tienes ninguna.';
@override String get serversLabel => 'Dirección web';
@override String get serversLabel => 'Servidores de la comunidad';
@override String get serversHelp => 'Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.';
@override String get serversAdvanced => 'Añadir otro servidor';
@override String get save => 'Guardar';
@override String get saved => 'Guardado';
@override String get wanted => 'Busco';
@ -1307,12 +1307,12 @@ extension on TranslationsEs {
'market.near' => 'Cerca de ti',
'market.contact' => 'Mensaje',
'market.configTitle' => 'Configuración de compartir',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Para activarlo, indica tu zona aproximada y únete a una comunidad de confianza — una red de semillas o un colectivo que mantiene un punto de encuentro compartido para pasar mensajes. Sin ninguna empresa en medio.',
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio. Puedes cambiar los servidores abajo.',
'market.areaLabel' => 'Tu zona',
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
'market.serversAdvanced' => 'Unirse a una comunidad (avanzado)',
'market.serversHelp' => 'Tu red de semillas o colectivo te da una dirección web para conectarte. Pega una por línea. Déjalo vacío si aún no tienes ninguna.',
'market.serversLabel' => 'Dirección web',
'market.serversLabel' => 'Servidores de la comunidad',
'market.serversHelp' => 'Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.',
'market.serversAdvanced' => 'Añadir otro servidor',
'market.save' => 'Guardar',
'market.saved' => 'Guardado',
'market.wanted' => 'Busco',

View file

@ -612,12 +612,12 @@ class _Translations$market$pt extends Translations$market$en {
@override String get near => 'Perto de ti';
@override String get contact => 'Mensagem';
@override String get configTitle => 'Configuração de partilha';
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.';
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio. Podes mudar os servidores abaixo.';
@override String get areaLabel => 'A tua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.';
@override String get serversAdvanced => 'Juntar-te a uma comunidade (avançado)';
@override String get serversHelp => 'A tua rede de sementes ou coletivo dá-te um endereço web para te ligares. Cola um por linha. Deixa vazio se ainda não tiveres nenhum.';
@override String get serversLabel => 'Endereço web';
@override String get serversLabel => 'Servidores da comunidade';
@override String get serversHelp => 'Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.';
@override String get serversAdvanced => 'Adicionar outro servidor';
@override String get save => 'Guardar';
@override String get saved => 'Guardado';
@override String get wanted => 'Procuro';
@ -1299,12 +1299,12 @@ extension on TranslationsPt {
'market.near' => 'Perto de ti',
'market.contact' => 'Mensagem',
'market.configTitle' => 'Configuração de partilha',
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Para ativar, indica a tua zona aproximada e junta-te a uma comunidade de confiança — uma rede de sementes ou um coletivo que mantém um ponto de encontro partilhado para passar mensagens. Sem nenhuma empresa no meio.',
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio. Podes mudar os servidores abaixo.',
'market.areaLabel' => 'A tua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.',
'market.serversAdvanced' => 'Juntar-te a uma comunidade (avançado)',
'market.serversHelp' => 'A tua rede de sementes ou coletivo dá-te um endereço web para te ligares. Cola um por linha. Deixa vazio se ainda não tiveres nenhum.',
'market.serversLabel' => 'Endereço web',
'market.serversLabel' => 'Servidores da comunidade',
'market.serversHelp' => 'Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.',
'market.serversAdvanced' => 'Adicionar outro servidor',
'market.save' => 'Guardar',
'market.saved' => 'Guardado',
'market.wanted' => 'Procuro',

View file

@ -1,12 +1,21 @@
import '../security/secret_store.dart';
/// A suggested community server the user can join with one tap.
class RelayPreset {
const RelayPreset(this.name, this.url);
final String name;
final String url;
}
/// 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.
///
/// Both default to "unset": empty area and no relays mean the social layer is
/// simply unavailable, and the app stays fully usable offline.
/// 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);
@ -15,6 +24,17 @@ class SocialSettings {
static const _areaKey = 'tane.social.area_geohash';
static const _relaysKey = 'tane.social.relays';
/// Suggested servers shown as one-tap options in the sharing setup.
static const List<RelayPreset> presets = [
RelayPreset('nos.lol', 'wss://nos.lol'),
RelayPreset('Damus', 'wss://relay.damus.io'),
RelayPreset('Primal', 'wss://relay.primal.net'),
];
/// Applied automatically for new users (a subset of [presets]) so sharing
/// works from the first launch, until the user changes it.
static const List<String> defaultRelays = ['wss://nos.lol', 'wss://relay.damus.io'];
/// 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)) ?? '';
@ -22,11 +42,12 @@ class SocialSettings {
Future<void> setAreaGeohash(String geohash) =>
_store.write(_areaKey, geohash.trim().toLowerCase());
/// Configured community relay URLs (empty by default the user adds their
/// own; we bundle none, to avoid leaking metadata to third-party relays).
/// 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 || raw.isEmpty) return const [];
if (raw == null) return const [...defaultRelays]; // never configured
return raw.split('\n').where((s) => s.trim().isNotEmpty).toList();
}

View file

@ -409,6 +409,7 @@ class _ConfigSheet extends StatefulWidget {
class _ConfigSheetState extends State<_ConfigSheet> {
final _area = TextEditingController();
final _servers = TextEditingController();
final Set<String> _selectedRelays = {};
bool _loading = true;
bool _locationBusy = false;
String? _locationError;
@ -421,13 +422,22 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Future<void> _load() async {
_area.text = await widget.settings.areaGeohash();
_servers.text = (await widget.settings.relayUrls()).join('\n');
final current = await widget.settings.relayUrls();
final presetUrls = SocialSettings.presets.map((p) => p.url).toSet();
_selectedRelays
..clear()
..addAll(current.where(presetUrls.contains));
// Any relays that aren't presets go in the "add another" field.
_servers.text = current.where((u) => !presetUrls.contains(u)).join('\n');
if (mounted) setState(() => _loading = false);
}
Future<void> _save() async {
await widget.settings.setAreaGeohash(_area.text);
await widget.settings.setRelayUrls(_servers.text.split('\n'));
await widget.settings.setRelayUrls([
..._selectedRelays,
..._servers.text.split('\n'),
]);
if (mounted) Navigator.of(context).pop(true);
}
@ -523,9 +533,40 @@ class _ConfigSheetState extends State<_ConfigSheet> {
style: const TextStyle(color: Color(0xFFB3261E), fontSize: 12),
),
),
const SizedBox(height: 8),
// The technical web address lives under progressive disclosure
// most people won't have one, and jargon shouldn't greet them.
const SizedBox(height: 16),
// Pick from a short list of shared servers pre-selected so it
// works out of the box; no jargon-y blank field to puzzle over.
Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: seedOnSurface,
),
),
Text(
t.market.serversHelp,
style: const TextStyle(color: seedMuted, fontSize: 12, height: 1.3),
),
for (final preset in SocialSettings.presets)
CheckboxListTile(
key: Key('market.relay.${preset.name}'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(preset.url),
title: Text(preset.name),
subtitle: Text(preset.url,
style: const TextStyle(fontSize: 11, color: seedMuted)),
onChanged: (checked) => setState(() {
if (checked ?? false) {
_selectedRelays.add(preset.url);
} else {
_selectedRelays.remove(preset.url);
}
}),
),
// A rarely-needed escape hatch for a community's own server.
Theme(
data: Theme.of(context)
.copyWith(dividerColor: Colors.transparent),
@ -535,21 +576,16 @@ class _ConfigSheetState extends State<_ConfigSheet> {
childrenPadding: const EdgeInsets.only(bottom: 8),
title: Text(
t.market.serversAdvanced,
style: const TextStyle(
fontSize: 14,
color: seedOnSurface,
),
style: const TextStyle(fontSize: 13, color: seedMuted),
),
children: [
TextField(
key: const Key('market.servers'),
controller: _servers,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: t.market.serversLabel,
helperText: t.market.serversHelp,
helperMaxLines: 4,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'wss://…',
),
),
],

View file

@ -15,12 +15,18 @@ void main() {
late SocialSettings settings;
setUp(() => settings = SocialSettings(FakeSecretStore()));
test('defaults: empty area, no relays, not configured', () async {
test('defaults: empty area, but relays fall back to the defaults', () async {
expect(await settings.areaGeohash(), '');
expect(await settings.relayUrls(), isEmpty);
expect(await settings.relayUrls(), SocialSettings.defaultRelays);
// Not configured yet: the area is still unset.
expect(await settings.isConfigured, isFalse);
});
test('an explicitly empty relay choice turns the network off', () async {
await settings.setRelayUrls(const []);
expect(await settings.relayUrls(), isEmpty);
});
test('stores and normalises the area geohash (trim + lowercase)', () async {
await settings.setAreaGeohash(' SP3E9 ');
expect(await settings.areaGeohash(), 'sp3e9');
@ -36,10 +42,9 @@ void main() {
['wss://relay.comunes.org', 'wss://seeds.example.org']);
});
test('is configured only with both an area and at least one relay', () async {
test('is configured once an area is set (relays are defaulted)', () async {
expect(await settings.isConfigured, isFalse); // no area yet
await settings.setAreaGeohash('sp3e9');
expect(await settings.isConfigured, isFalse);
await settings.setRelayUrls(['wss://relay.comunes.org']);
expect(await settings.isConfigured, isTrue);
});
}

View file

@ -13,6 +13,7 @@ void main() {
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget(

View file

@ -110,6 +110,7 @@ void main() {
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget(
@ -136,6 +137,7 @@ void main() {
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
LocaleSettings.setLocaleSync(AppLocale.en);
await tester.pumpWidget(