mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Wes
Claude Opus 4.7
parent
454781caae
commit
ea62664759
@@ -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
|
||||
|
||||
@@ -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) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user