post-475 fixes (#481)

Co-authored-by: Wes <wesb@block.xyz>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tlongwell-block
2026-05-05 14:41:18 -04:00
committed by GitHub
co-authored by Wes Claude Opus 4.7
parent 454781caae
commit ea62664759
7 changed files with 215 additions and 33 deletions
+17 -3
View File
@@ -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
+37 -2
View File
@@ -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) ──────────────────────────────────────────────────
+37 -4
View File
@@ -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]
+16 -16
View File
@@ -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 }
}
@@ -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<Channel>? channels;
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final String? currentPubkey;
final Future<void> 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,
),
);
}
}
@@ -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<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) {
@@ -86,7 +136,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
(_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<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,
@@ -112,7 +177,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
),
memberCount: 0,
lastMessageAt: null,
participants: const [],
participants: participants,
participantPubkeys: data.participantPubkeys,
isMember: isMember,
);
@@ -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);