Skip to content

Commit 728abfe

Browse files
grunchMostronatorCoder
andauthored
feat: add community discovery and node selector on first launch (#564)
* feat: add community discovery and node selector on first launch (#563) Add a community selector screen shown after the walkthrough for new users, allowing them to choose a trusted Mostro community before reaching the home screen. Fetches kind 0 and kind 38385 metadata from relay.mostro.network via standalone WebSocket connection. Includes trusted communities for Cuba, Spain, Colombia and Bolivia, search filtering, custom node support, loading skeleton, error handling, and full i18n (en/es/it). Existing users are not interrupted thanks to backward-compatible auto-migration. * fix: address code review findings for community selector - Remove duplicate skipForNow keys from all ARB files - Fix race condition: treat loading state as unselected in redirect - Add mounted checks before state mutations in CommunitySelectedNotifier - Remove manual string truncation, rely on TextOverflow.ellipsis - Add try-catch to launchUrl for social links * refactor: address code review findings for community selector - Move WebSocket fetcher to CommunityRepository in data/repositories - Propagate fetch errors so communityListProvider shows error state - Fix dynamic _prefs type to SharedPreferencesAsync for type safety - Await _ensureNodeExists to prevent race with selectNode - Use set-diff for robust custom node detection after dialog - Derive Config.trustedMostroNodes from trustedCommunities (single source) - Add loadingBuilder to avatar for smooth image loading * fix: resolve dependency conflict by removing riverpod_generator Replace @riverpod annotations with manual providers in the 3 files that used code generation (event_bus, mostro_service_provider, order_notifier_provider). This eliminates the riverpod_generator and riverpod_annotation dependencies whose analyzer requirement conflicted with the test package on Flutter 3.41.6. * fix: upgrade google_fonts to 6.3.3 for Flutter 3.41.6 compatibility * refactor: remove duplicated default pubkey constant from Config * fix: make MostroService.init() idempotent to prevent subscription leak * fix: update tests to derive expected node name from Config instead of hardcoding * feat: add German and French translations for community selector * feat: show 'All currencies' when Mostro node accepts all fiat currencies * docs: add community discovery implementation spec * fix: verify Nostr event signatures in CommunityRepository and filter nodes without trade info Add NIP-01 signature verification (event ID + Schnorr) before processing kind 0 and kind 38385 events to prevent relay-injected spoofed metadata. Filter out community nodes that lack valid kind 38385 trade info from the selector. * fix: add relay fallback for community metadata fetching Replace single hardcoded relay with Config.nostrRelays list, trying each relay in order until one succeeds. Prevents community selector from breaking when the primary relay is down. * fix: handle partial EOSE responses gracefully in community metadata fetch Return partial results when timeout fires but events were already received, instead of discarding them. Only rethrow to try the next relay when zero events arrived. * fix: set light status bar icons on dark backgrounds Add SystemUiOverlayStyle.light to AppBarTheme for screens with AppBar, and wrap CommunitySelectorScreen with AnnotatedRegion since it lacks an AppBar. * fix: persist community selection across app restarts Fix race condition where the router redirect evaluated communitySelectedProvider while still loading from SharedPreferences, causing the community selector to show on every launch. Skip redirect during loading and refresh the router when the provider resolves. * uncommented ref.onDispose(bus.dispose) so the StreamController is properly closed when the provider is disposed. * fix: harden community selector with error handling, locale formatting, and verification tracking - Add error handling in _selectAndProceed with user-facing SnackBar - Track hasEvents only after verified events are applied, so unverified relays fall through to the next relay instead of returning empty results - Use locale-aware NumberFormat.compact for sats formatting in cards - Add language tags to fenced code blocks in COMMUNITY_DISCOVERY.md - Update doc to match current redirect and relay fallback behavior - Remove duplicated test helper in favor of CommunityRepository.verifyEvent * feat: add community disclaimer modal and settings warning - Add disclaimer modal to community selector screen shown on first load - Add disclaimer warning below Mostro node selector in settings - Add i18n strings for disclaimer (EN, ES, FR, DE, IT) - Disclaimer: Mostro team not responsible for node operator actions * fix: fail over to next relay when WebSocket closes without verified events The onDone handler now completes with an error when the connection closes early without any verified events and before both EOSEs arrived, allowing fetchCommunityMetadata to try the next relay instead of returning empty results. --------- Co-authored-by: MostronatorCoder <mostronator@mostro.network>
1 parent 856469e commit 728abfe

27 files changed

Lines changed: 1931 additions & 172 deletions

docs/COMMUNITY_DISCOVERY.md

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
# Community Discovery: Node Selector on First Launch
2+
3+
**Issue:** [#563](https://github.com/MostroP2P/mobile/issues/563)
4+
**Reference:** [mostro.community](https://github.com/MostroP2P/community)
5+
6+
## Overview
7+
8+
Community Discovery allows new users to choose a trusted Mostro community/node on their first launch, before reaching the home screen. The feature mirrors the [mostro.community](https://mostro.community) website pattern: trusted node pubkeys are hardcoded, and community metadata (name, avatar, description, currencies, fee) is fetched from Nostr kind 0 and kind 38385 events.
9+
10+
Existing users are never interrupted. The selector is shown only once after the walkthrough.
11+
12+
## User Flow
13+
14+
### New User
15+
16+
```text
17+
App install -> Walkthrough (complete or skip) -> Community Selector -> Home
18+
```
19+
20+
### Existing User (upgrade)
21+
22+
```text
23+
App launch -> Home (auto-migrated, no interruption)
24+
```
25+
26+
### Returning to Community Selection
27+
28+
```text
29+
Home -> Settings -> Mostro Card -> Node Selector (existing feature)
30+
```
31+
32+
## Trusted Communities
33+
34+
Mirrored from [mostro.community](https://github.com/MostroP2P/community). Defined in `lib/core/config/communities.dart`:
35+
36+
| Region | Pubkey (truncated) | Social |
37+
|--------|-------------------|--------|
38+
| Cuba | `00000235a3e9...1366a` | [Telegram](https://t.me/Cuba_Bitcoin), [Website](https://cubabitcoin.org/kmbalache/) |
39+
| Spain | `0000cc02101e...36b40` | [Telegram](https://t.me/nostromostro) |
40+
| Colombia | `00000978acc5...8441b` | [Telegram](https://t.me/ColombiaP2P), [X](https://x.com/ColombiaP2P) |
41+
| Bolivia | `00007cb3305f...3f91` | [Telegram](https://t.me/btcxbolivia), [X](https://x.com/btcxbolivia), [Instagram](https://www.instagram.com/btcxbolivia) |
42+
| Default | `82fa8cb978b4...8390` | (fallback when user skips) |
43+
44+
**Single source of truth:** `Config.trustedMostroNodes` is derived from `trustedCommunities` at runtime, eliminating duplication between the node system and community config.
45+
46+
## Architecture
47+
48+
### Data Flow
49+
50+
```text
51+
trustedCommunities (static config)
52+
|
53+
v
54+
CommunityRepository.fetchCommunityMetadata(pubkeys)
55+
| WebSocket -> Config.nostrRelays (with fallback)
56+
| REQ kind 0 (profile: name, about, picture)
57+
| REQ kind 38385 (trade info: currencies, fee, min/max)
58+
| Timeout: 10s, partial data OK
59+
v
60+
communityListProvider (FutureProvider)
61+
| Merges static config + fetched metadata
62+
v
63+
CommunitySelectorScreen
64+
| User selects community -> selectNode() + markCommunitySelected()
65+
v
66+
Home Screen
67+
```
68+
69+
### Layer Responsibilities
70+
71+
| Layer | Component | Responsibility |
72+
|-------|-----------|---------------|
73+
| Config | `communities.dart` | `CommunityConfig`, `SocialLink`, `trustedCommunities`, `defaultMostroPubkey` |
74+
| Data | `community_repository.dart` | `CommunityRepository` (WebSocket fetch), `CommunityMetadata` (parsed event data) |
75+
| Domain | `community.dart` | `Community` model combining static config + dynamic metadata |
76+
| State | `community_selector_provider.dart` | `communitySelectedProvider` (persistence), `communityRepositoryProvider` (DI), `communityListProvider` (fetch + enrich) |
77+
| UI | `community_selector_screen.dart` | Full-screen selector with search, loading skeleton, error state |
78+
| UI | `community_card.dart` | Card widget: avatar, name, region, about, currencies, fee/range, social links |
79+
80+
### File Structure
81+
82+
```text
83+
lib/
84+
core/
85+
config/
86+
communities.dart # Trusted pubkeys, SocialLink, CommunityConfig
87+
config.dart # Config.trustedMostroNodes (derived)
88+
data/
89+
models/enums/
90+
storage_keys.dart # + communitySelected key
91+
repositories/
92+
community_repository.dart # WebSocket fetcher + CommunityMetadata
93+
features/
94+
community/
95+
community.dart # Community model
96+
providers/
97+
community_selector_provider.dart # Riverpod providers
98+
screens/
99+
community_selector_screen.dart # Main screen
100+
widgets/
101+
community_card.dart # Card widget
102+
walkthrough/
103+
screens/
104+
walkthrough_screen.dart # Modified: navigates to /community_selector
105+
```
106+
107+
## Nostr Event Fetching
108+
109+
### CommunityRepository
110+
111+
Located at `lib/data/repositories/community_repository.dart`. Uses a standalone `dart:io` WebSocket connection, independent of the app's `NostrService`, so it works before full app initialization.
112+
113+
**Connection:** `wss://relay.mostro.network`
114+
**Timeout:** 10 seconds
115+
**Error handling:** Errors propagate to `communityListProvider` which shows the error state with retry.
116+
117+
### Subscriptions
118+
119+
Two concurrent REQ messages on a single WebSocket:
120+
121+
```json
122+
// Kind 0: Nostr profile metadata
123+
["REQ", "<subId>", {"kinds": [0], "authors": ["<pubkey1>", "<pubkey2>", ...]}]
124+
125+
// Kind 38385: Mostro trade information
126+
["REQ", "<subId>", {"kinds": [38385], "authors": ["<pubkey1>", ...], "#y": ["mostro"]}]
127+
```
128+
129+
Waits for both EOSE responses (or timeout), then closes subscriptions and WebSocket.
130+
131+
### Event Deduplication
132+
133+
For both kinds, keeps only the event with the highest `created_at` per pubkey. This handles multiple relays returning the same event.
134+
135+
### Kind 0 Fields Extracted
136+
137+
| Field | JSON key | Usage |
138+
|-------|----------|-------|
139+
| Name | `name` | Display name (fallback: region from config) |
140+
| About | `about` | Description text on card |
141+
| Picture | `picture` | Avatar (HTTPS only, NymAvatar fallback) |
142+
143+
### Kind 38385 Tags Extracted
144+
145+
| Tag | Usage |
146+
|-----|-------|
147+
| `fiat_currencies_accepted` | Comma-separated currency codes displayed as tags |
148+
| `fee` | Trading fee percentage |
149+
| `min_order_amount` | Minimum order in sats |
150+
| `max_order_amount` | Maximum order in sats |
151+
152+
### "All Currencies" Logic
153+
154+
When kind 38385 event exists (`hasTradeInfo = true`) but `fiat_currencies_accepted` is empty or absent, the card displays a localized "All currencies" tag. This mirrors the behavior of [mostro.community](https://mostro.community).
155+
156+
## Navigation Integration
157+
158+
### GoRouter Redirect Chain
159+
160+
In `lib/core/app_routes.dart`, the redirect logic evaluates two providers sequentially:
161+
162+
```text
163+
1. firstRunProvider:
164+
- loading -> redirect to /walkthrough
165+
- data(isFirstRun=true) -> redirect to /walkthrough
166+
- data(isFirstRun=false) -> proceed to step 2
167+
168+
2. communitySelectedProvider:
169+
- loading -> no redirect (wait for provider to resolve; router refreshes on change)
170+
- data(false) -> redirect to /community_selector
171+
- data(true) -> no redirect (proceed to requested route)
172+
- error -> no redirect (don't block on errors)
173+
```
174+
175+
### Route Definition
176+
177+
```dart
178+
GoRoute(
179+
path: '/community_selector',
180+
pageBuilder: (context, state) => buildPageWithDefaultTransition<void>(
181+
context: context,
182+
state: state,
183+
child: const CommunitySelectorScreen(),
184+
),
185+
),
186+
```
187+
188+
### Walkthrough Integration
189+
190+
`WalkthroughScreen._onIntroEnd()` navigates to `/community_selector` instead of `/`. The GoRouter redirect handles the rest.
191+
192+
## Backward Compatibility
193+
194+
### Existing Users Auto-Migration
195+
196+
`CommunitySelectedNotifier._init()` checks:
197+
198+
1. If `communitySelected` is already `true` in SharedPreferences -> done.
199+
2. If `firstRunComplete` is `true` (existing user who completed onboarding before this feature) -> auto-sets `communitySelected = true` and skips the selector.
200+
3. Otherwise -> `false` (new user, show selector).
201+
202+
This ensures users who upgrade from a version without community discovery are never interrupted.
203+
204+
### Config.trustedMostroNodes
205+
206+
Previously hardcoded as a single entry (`Mostro P2P`). Now derived from `trustedCommunities`, which contains all 5 community entries. The `MostroNodesNotifier` initializes from this list, so all communities appear as trusted nodes in the existing node selector (Settings -> Mostro).
207+
208+
## State Management
209+
210+
### communitySelectedProvider
211+
212+
`StateNotifierProvider<CommunitySelectedNotifier, AsyncValue<bool>>`
213+
214+
- **Storage:** `SharedPreferencesKeys.communitySelected` (`'community_selected'`)
215+
- **Loading:** Reads SharedPreferences asynchronously
216+
- **Mounted checks:** All async `state =` assignments guarded by `if (!mounted) return`
217+
- **Methods:** `markCommunitySelected()` — persists selection and updates state
218+
219+
### communityListProvider
220+
221+
`FutureProvider<List<Community>>`
222+
223+
1. Creates `Community.fromConfig()` for each `trustedCommunities` entry
224+
2. Calls `CommunityRepository.fetchCommunityMetadata()` with all pubkeys
225+
3. Enriches communities with fetched metadata via `copyWith()`
226+
4. Returns enriched list (partial data is fine — missing metadata fields stay null)
227+
228+
### communityRepositoryProvider
229+
230+
`Provider<CommunityRepository>` — simple factory for dependency injection.
231+
232+
## Community Selector Screen
233+
234+
### UI Layout
235+
236+
```text
237+
+-----------------------------+
238+
| bolt Choose your community | <- Title with bolt icon
239+
| [search icon] Search... | <- Search bar (filters by name, region, currency, about)
240+
| |
241+
| +-------------------------+ |
242+
| | Avatar Name check | | <- CommunityCard (selected state)
243+
| | Region | |
244+
| | Description text... | |
245+
| | [USD] [EUR] [CUP] | | <- Currency tags (or "All currencies")
246+
| | % Fee 1.0% | Range ... | | <- Fee and sats range
247+
| | tg x ig | | <- Social link icons
248+
| +-------------------------+ |
249+
| [more cards...] |
250+
| |
251+
| gear Use a custom node | <- Opens AddCustomNodeDialog
252+
| [========= Done =========] | <- Confirm button (visible after selection)
253+
| Skip for now | <- Uses defaultMostroPubkey
254+
+-----------------------------+
255+
```
256+
257+
### States
258+
259+
| State | Behavior |
260+
|-------|----------|
261+
| **Loading** | Skeleton placeholders (same count as `trustedCommunities`) |
262+
| **Error** | Cloud-off icon + error message + retry button (invalidates `communityListProvider`) |
263+
| **Data (empty search)** | "No communities found" centered text |
264+
| **Data** | Scrollable list of `CommunityCard` widgets |
265+
| **Selecting** | Loading spinner on confirm button, all interactions disabled |
266+
267+
### User Actions
268+
269+
| Action | Behavior |
270+
|--------|----------|
271+
| **Tap card** | Sets `_selectedPubkey`, shows confirm button |
272+
| **Confirm** | `_selectAndProceed()`: ensures node exists, selects it, marks community selected, navigates to `/` |
273+
| **Skip** | Same as confirm but uses `defaultMostroPubkey` |
274+
| **Use custom node** | Opens `AddCustomNodeDialog`; if a node was added (detected via set-diff on pubkeys), auto-selects it and proceeds |
275+
276+
### _selectAndProceed() Flow
277+
278+
```dart
279+
1. _ensureNodeExists(pubkey) // Adds as custom node if not already known (awaited)
280+
2. nodesNotifier.selectNode() // Calls settingsNotifier.updateMostroInstance()
281+
3. markCommunitySelected() // Persists to SharedPreferences
282+
4. context.go('/') // Navigate to home (if still mounted)
283+
```
284+
285+
## CommunityCard Widget
286+
287+
`StatelessWidget` displaying a single community entry.
288+
289+
### Sections (conditional)
290+
291+
1. **Header:** Avatar (network image with NymAvatar fallback + loading placeholder) + display name + region + check icon (if selected)
292+
2. **About:** Description text, max 3 lines with ellipsis
293+
3. **Currencies:** Green tags showing accepted fiat codes, or "All currencies" if `hasTradeInfo && currencies.isEmpty`
294+
4. **Stats:** Fee percentage + sats range (formatted as K/M)
295+
5. **Social:** Tappable icons for Telegram, X, Instagram, etc.
296+
297+
### Visual Design
298+
299+
- Uses `AppTheme` constants consistently (backgroundCard, activeColor, textPrimary, textSecondary)
300+
- `AnimatedContainer` with 200ms transition for selection state
301+
- Selected state: green border (alpha 0.5) + green background tint (alpha 0.1) + check icon
302+
- Currency tags: green background (alpha 0.15) + green text
303+
304+
## Internationalization
305+
306+
All user-facing strings use `S.of(context)!.keyName`. Keys added across all 5 locales:
307+
308+
| Key | EN | ES | IT | DE | FR |
309+
|-----|----|----|----|----|-----|
310+
| `chooseYourCommunity` | Choose your community | Elige tu comunidad | Scegli la tua comunita | Wahle deine Community | Choisissez votre communaute |
311+
| `communitySearchHint` | Search communities... | Buscar comunidades... | Cerca comunita... | Communities suchen... | Rechercher des communautes... |
312+
| `communityFee` | Fee | Comision | Commissione | Gebuhr | Frais |
313+
| `communityRange` | Range | Rango | Intervallo | Bereich | Plage |
314+
| `useCustomNode` | Use a custom node | Usar un nodo personalizado | Usa un nodo personalizzato | Eigenen Knoten verwenden | Utiliser un noeud personnalise |
315+
| `communityLoadingError` | Could not load community data | No se pudieron cargar... | Impossibile caricare... | ...konnten nicht geladen werden | Impossible de charger... |
316+
| `communityRetry` | Retry | Reintentar | Riprova | Erneut versuchen | Reessayer |
317+
| `noCommunityResults` | No communities found | No se encontraron... | Nessuna comunita trovata | Keine Communities gefunden | Aucune communaute trouvee |
318+
| `communityFormatSats` | {amount} sats | {amount} sats | {amount} sats | {amount} sats | {amount} sats |
319+
| `communityAllCurrencies` | All currencies | Todas las monedas | Tutte le valute | Alle Wahrungen | Toutes les devises |
320+
321+
The existing `skipForNow` and `done` keys are reused from prior translations.
322+
323+
## Ancillary Changes
324+
325+
### Dependency Conflict Resolution
326+
327+
Removed `riverpod_generator` and `riverpod_annotation` dev dependencies that conflicted with Flutter 3.41.6's `analyzer` requirement. The 3 files using `@riverpod` annotations were converted to manual providers matching the rest of the codebase:
328+
329+
| File | Change |
330+
|------|--------|
331+
| `lib/services/event_bus.dart` | `@riverpod` -> `AutoDisposeProvider` |
332+
| `lib/shared/providers/mostro_service_provider.dart` | `@Riverpod(keepAlive: true)` -> `Provider` |
333+
| `lib/features/order/providers/order_notifier_provider.dart` | `@riverpod class OrderTypeNotifier` -> `StateNotifier` + `AutoDisposeStateNotifierProvider` |
334+
335+
### google_fonts Upgrade
336+
337+
Upgraded `google_fonts` from 6.2.1 to 6.3.3 (within existing `^6.2.1` constraint) to fix `FontWeight` constant evaluation error on Flutter 3.41.6.
338+
339+
### MostroService.init() Idempotency
340+
341+
Added `_ordersSubscription?.cancel()` at the start of `MostroService.init()` to prevent subscription leaks when called from `LifecycleManager.onResumed()`.
342+
343+
## Out of Scope (v1)
344+
345+
- Decentralized community discovery via NIP
346+
- Real-time updates when community changes their kind 38385
347+
- User-created communities (curated list only)
348+
- Settings screen community section (uses existing Mostro node selector)

lib/core/app.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:mostro_mobile/features/notifications/services/background_notific
1616
import 'package:mostro_mobile/shared/providers/app_init_provider.dart';
1717
import 'package:mostro_mobile/features/settings/settings_provider.dart';
1818
import 'package:mostro_mobile/shared/notifiers/locale_notifier.dart';
19+
import 'package:mostro_mobile/features/community/providers/community_selector_provider.dart';
1920
import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart';
2021
import 'package:mostro_mobile/features/restore/restore_overlay.dart';
2122
import 'package:mostro_mobile/shared/widgets/nwc_notification_listener.dart';
@@ -122,8 +123,12 @@ class _MostroAppState extends ConsumerState<MostroApp> {
122123

123124
return initAsyncValue.when(
124125
data: (_) {
125-
// Initialize first run provider
126+
// Watch providers that affect routing
126127
ref.watch(firstRunProvider);
128+
// Refresh router when community selection state resolves
129+
ref.listen(communitySelectedProvider, (_, __) {
130+
_router?.refresh();
131+
});
127132

128133
ref.listen<AuthState>(authNotifierProvider, (previous, state) {
129134
WidgetsBinding.instance.addPostFrameCallback((_) {

0 commit comments

Comments
 (0)