Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions crates/sprout-relay/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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) ──────────────────────────────────────────────────
Expand Down
41 changes: 37 additions & 4 deletions desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,51 @@ pub async fn search_users(
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchUsersResponse, String> {
// 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::<serde_json::Value>(&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]
Expand Down
32 changes: 16 additions & 16 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
33 changes: 31 additions & 2 deletions mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
Expand All @@ -163,6 +182,7 @@ class ChannelsPage extends HookConsumerWidget {
class _ChannelsBody extends StatelessWidget {
final List<Channel>? channels;
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final String? currentPubkey;
final Future<void> Function() onRefresh;
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
),
);
}
}
Expand Down
75 changes: 70 additions & 5 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -75,18 +76,68 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
NostrFilters.channelMetadata(channelIds),
);

final channels = <Channel>[];
// 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 = <String, NostrEvent>{};
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 = <String>{};
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 = <String, String>{};
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 = <Channel>[];
for (final event in dedupedMetas) {
channels.add(
_channelFromMeta(event, isMember: true, displayNames: displayNames),
);
}

channels.sort((left, right) {
final typeOrder =
(_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) {
Expand All @@ -96,8 +147,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}

/// 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<String, String> displayNames = const {},
}) {
final data = ChannelData.fromEvent(event);
final participants = data.channelType == 'dm'
? [
for (final pk in data.participantPubkeys)
displayNames[pk.toLowerCase()] ?? shortPubkey(pk),
]
: const <String>[];
return Channel(
id: data.id,
name: data.name,
Expand All @@ -112,7 +177,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
),
memberCount: 0,
lastMessageAt: null,
participants: const [],
participants: participants,
participantPubkeys: data.participantPubkeys,
isMember: isMember,
);
Expand Down
8 changes: 7 additions & 1 deletion mobile/test/features/channels/channels_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down