diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index d6471ad5..5ee724b4 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -183,10 +183,24 @@ async fn main() -> anyhow::Result<()> { let relay_keypair = if let Some(hex) = &config.relay_private_key { nostr::Keys::parse(hex) .map_err(|e| anyhow::anyhow!("invalid SPROUT_RELAY_PRIVATE_KEY: {e}"))? - } else { - let keys = nostr::Keys::generate(); - tracing::info!("Generated relay keypair: {}", keys.public_key().to_hex()); + } else if !config.require_auth_token { + // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) + // replace correctly across restarts. Without this, each restart generates a new pubkey + // and replace_addressable_event inserts duplicates instead of replacing. + const DEV_RELAY_PRIVKEY: &str = + "0000000000000000000000000000000000000000000000000000000000000001"; + let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); + tracing::warn!( + pubkey = %keys.public_key().to_hex(), + "Using hardcoded dev relay keypair (SPROUT_REQUIRE_AUTH_TOKEN=false). \ + Set SPROUT_RELAY_PRIVATE_KEY for production." + ); keys + } else { + panic!( + "SPROUT_RELAY_PRIVATE_KEY must be set when SPROUT_REQUIRE_AUTH_TOKEN=true. \ + A stable relay identity is required for production." + ); }; config diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 72787567..7d9d79b5 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -110,11 +110,46 @@ pub async fn get_channel_members( ) .await?; - events + let mut response = events .first() .map(nostr_convert::channel_members_from_event) .transpose()? - .ok_or_else(|| "channel members not found".to_string()) + .ok_or_else(|| "channel members not found".to_string())?; + + // Batch-fetch kind:0 profiles to populate display names. + let pubkeys: Vec = response.members.iter().map(|m| m.pubkey.clone()).collect(); + if !pubkeys.is_empty() { + let profile_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + "limit": pubkeys.len() + })], + ) + .await + .unwrap_or_default(); + + // Build pubkey → display_name map from kind:0 events + let mut name_map = std::collections::HashMap::new(); + for ev in &profile_events { + let pk = ev.pubkey.to_hex(); + if let Ok(profile) = nostr_convert::profile_info_from_event(ev) { + if let Some(name) = profile.display_name { + name_map.insert(pk, name); + } + } + } + + // Populate display_name on each member + for member in &mut response.members { + if member.display_name.is_none() { + member.display_name = name_map.get(&member.pubkey).cloned(); + } + } + } + + Ok(response) } // ── Writes (signed events) ────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 6a4fa5bd..ff3d4e3f 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -177,18 +177,51 @@ pub async fn search_users( limit: Option, state: State<'_, AppState>, ) -> Result { - // NIP-50 search filter on kind:0. + let q = query.trim().to_lowercase(); + let max = limit.unwrap_or(8).min(50) as usize; + + if q.is_empty() { + return Ok(SearchUsersResponse { users: Vec::new() }); + } + + // Fetch all kind:0 profiles and filter client-side. The old REST endpoint + // used a DB ILIKE query; this is equivalent for small-to-medium relays. + // NIP-50 search doesn't work well for user lookup because Typesense indexes + // raw JSON content and short names don't tokenize at JSON boundaries. let events = query_relay( &state, &[serde_json::json!({ "kinds": [0], - "search": query, - "limit": limit.unwrap_or(8).min(50), + "limit": 2000, })], ) .await?; - Ok(nostr_convert::search_users_from_events(&events)) + let mut users = Vec::new(); + for ev in &events { + let pubkey_hex = ev.pubkey.to_hex(); + if let Ok(v) = serde_json::from_str::(&ev.content) { + let display_name = v + .get("display_name") + .and_then(|v| v.as_str()) + .or_else(|| v.get("name").and_then(|v| v.as_str())) + .unwrap_or(""); + let nip05 = v.get("nip05").and_then(|v| v.as_str()).unwrap_or(""); + + let matches = display_name.to_lowercase().contains(&q) + || nip05.to_lowercase().contains(&q) + || pubkey_hex.starts_with(&q); + + if matches { + users.push(nostr_convert::user_search_result_from_event(ev)); + if users.len() >= max { + break; + } + } + } + } + + Ok(SearchUsersResponse { users }) } #[tauri::command] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ae9fce78..d06bd863 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -312,23 +312,23 @@ pub fn users_batch_from_events( } /// Convert kind:0 events (e.g. from a NIP-50 search) to [`SearchUsersResponse`]. +/// Convert a single kind:0 event to a [`UserSearchResultInfo`]. +pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { + let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + UserSearchResultInfo { + pubkey: ev.pubkey.to_hex(), + display_name: v + .get("display_name") + .and_then(Value::as_str) + .or_else(|| v.get("name").and_then(Value::as_str)) + .map(str::to_string), + avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), + } +} + pub fn search_users_from_events(events: &[Event]) -> SearchUsersResponse { - let users = events - .iter() - .map(|ev| { - let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); - UserSearchResultInfo { - pubkey: ev.pubkey.to_hex(), - display_name: v - .get("display_name") - .and_then(Value::as_str) - .or_else(|| v.get("name").and_then(Value::as_str)) - .map(str::to_string), - avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), - nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), - } - }) - .collect(); + let users = events.iter().map(user_search_result_from_event).collect(); SearchUsersResponse { users } } diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5567f1ef..478b624a 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -123,6 +123,24 @@ class ChannelsPage extends HookConsumerWidget { } } + // Defer the error view to absorb transient AsyncError frames caused by + // the relay session cancelling in-flight history fetches on disconnect/ + // reconnect (relay_session.dart `_cancelAllHistory`). If the error clears + // (channels populate or the next _fetch succeeds) within the grace + // window, we never render the error UI. + final showError = useState(false); + final hasError = channelsAsync.hasError && channels == null; + useEffect(() { + if (!hasError) { + showError.value = false; + return null; + } + final timer = Timer(const Duration(seconds: 2), () { + showError.value = true; + }); + return timer.cancel; + }, [hasError]); + return FrostedScaffold( appBar: FrostedAppBar( leading: _WorkspaceIndicator( @@ -151,6 +169,7 @@ class ChannelsPage extends HookConsumerWidget { body: _ChannelsBody( channels: channels, channelsAsync: channelsAsync, + showError: showError.value, sessionStatus: sessionState.status, currentPubkey: currentPubkey, onRefresh: () => ref.read(channelsProvider.notifier).refresh(), @@ -163,6 +182,7 @@ class ChannelsPage extends HookConsumerWidget { class _ChannelsBody extends StatelessWidget { final List? channels; final AsyncValue> channelsAsync; + final bool showError; final SessionStatus sessionStatus; final String? currentPubkey; final Future Function() onRefresh; @@ -171,6 +191,7 @@ class _ChannelsBody extends StatelessWidget { const _ChannelsBody({ required this.channels, required this.channelsAsync, + required this.showError, required this.sessionStatus, required this.currentPubkey, required this.onRefresh, @@ -214,7 +235,11 @@ class _ChannelsBody extends StatelessWidget { ); } - if (channelsAsync.hasError) { + // The error view is gated on a grace timer in the parent — see the + // useEffect in ChannelsPage. While the grace window is in flight we fall + // through to the connection banner so transient relay-cancellation errors + // don't flash the error UI. + if (showError && channelsAsync.hasError) { return Padding( padding: EdgeInsets.only(top: barHeight), child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh), @@ -223,7 +248,11 @@ class _ChannelsBody extends StatelessWidget { return Padding( padding: EdgeInsets.only(top: barHeight), - child: const _ConnectionBanner(status: SessionStatus.connecting), + child: _ConnectionBanner( + status: sessionStatus == SessionStatus.connected + ? SessionStatus.connecting + : sessionStatus, + ), ); } } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 9e85b1b0..c91be499 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/utils/string_utils.dart'; import 'channel.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; @@ -75,10 +76,59 @@ class ChannelsNotifier extends AsyncNotifier> { NostrFilters.channelMetadata(channelIds), ); - final channels = []; + // Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable, + // so logically there's exactly one current event per id, but stale revisions + // from before the relay's d_tag backfill can linger. Keep the highest + // `created_at` per id so the latest channel_type / name wins. + final latestMetaPerId = {}; for (final event in metas) { if (event.kind != 39000) continue; - channels.add(_channelFromMeta(event, isMember: true)); + final id = event.getTagValue('d'); + if (id == null) continue; + final existing = latestMetaPerId[id]; + if (existing == null || event.createdAt > existing.createdAt) { + latestMetaPerId[id] = event; + } + } + final dedupedMetas = latestMetaPerId.values; + + // Resolve DM participant display names. Relay stores DM channels with + // literal name="DM"; pure-Nostr architecture pushes name resolution to + // the client, so collect non-self participant pubkeys across all DM + // metas and batch-fetch their kind:0 profiles in one round-trip. + final dmParticipants = {}; + final myPkLower = myPk.toLowerCase(); + for (final event in dedupedMetas) { + final data = ChannelData.fromEvent(event); + if (data.channelType != 'dm') continue; + for (final pk in data.participantPubkeys) { + final lower = pk.toLowerCase(); + if (lower != myPkLower) dmParticipants.add(lower); + } + } + + final displayNames = {}; + if (dmParticipants.isNotEmpty) { + final profileEvents = await session.fetchHistory( + NostrFilters.profilesBatch(dmParticipants.toList()), + ); + for (final event in profileEvents) { + if (event.kind != 0) continue; + final profile = ProfileData.fromEvent(event); + final label = profile.displayName?.trim().isNotEmpty == true + ? profile.displayName!.trim() + : profile.nip05?.trim().isNotEmpty == true + ? profile.nip05!.trim() + : shortPubkey(profile.pubkey); + displayNames[profile.pubkey.toLowerCase()] = label; + } + } + + final channels = []; + for (final event in dedupedMetas) { + channels.add( + _channelFromMeta(event, isMember: true, displayNames: displayNames), + ); } channels.sort((left, right) { @@ -86,7 +136,8 @@ class ChannelsNotifier extends AsyncNotifier> { (_channelTypeOrder[left.channelType] ?? 99) - (_channelTypeOrder[right.channelType] ?? 99); if (typeOrder != 0) return typeOrder; - return left.name.compareTo(right.name); + // Case-insensitive to match desktop's `localeCompare` ordering. + return left.name.toLowerCase().compareTo(right.name.toLowerCase()); }); if (subscribeLive) { @@ -96,8 +147,22 @@ class ChannelsNotifier extends AsyncNotifier> { } /// Build a [Channel] from a kind:39000 metadata event. - Channel _channelFromMeta(NostrEvent event, {required bool isMember}) { + /// + /// [displayNames] maps lowercase participant pubkey → resolved label and is + /// used to populate [Channel.participants] for DMs so [Channel.displayLabel] + /// can render real names instead of the relay-canonical "DM" name. + Channel _channelFromMeta( + NostrEvent event, { + required bool isMember, + Map displayNames = const {}, + }) { final data = ChannelData.fromEvent(event); + final participants = data.channelType == 'dm' + ? [ + for (final pk in data.participantPubkeys) + displayNames[pk.toLowerCase()] ?? shortPubkey(pk), + ] + : const []; return Channel( id: data.id, name: data.name, @@ -112,7 +177,7 @@ class ChannelsNotifier extends AsyncNotifier> { ), memberCount: 0, lastMessageAt: null, - participants: const [], + participants: participants, participantPubkeys: data.participantPubkeys, isMember: isMember, ); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 0b0081c4..a6069bc2 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -143,7 +143,13 @@ void main() { overrides: [channelsProvider.overrideWith(() => _ErrorNotifier())], ), ); - await tester.pumpAndSettle(); + // The error view is gated on a grace timer in ChannelsPage to absorb + // transient AsyncError frames during relay reconnect. Pump once to mount + // and schedule the timer, advance the fake clock past the grace window, + // then pump again to flush the setState the timer triggered. + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + await tester.pump(); expect(find.text('Could not load channels'), findsOneWidget); expect(find.text('Retry'), findsOneWidget);