Redesign for the v1 stable release #3

Merged
reya merged 30 commits from v1-redesign into master 2026-02-04 01:43:24 +00:00
7 changed files with 456 additions and 148 deletions
Showing only changes of commit 89fd00ddef - Show all commits

2
Cargo.lock generated
View File

@@ -6076,6 +6076,8 @@ dependencies = [
"nostr-sdk", "nostr-sdk",
"reqwest", "reqwest",
"rustls", "rustls",
"serde",
"serde_json",
"smol", "smol",
"webbrowser", "webbrowser",
] ]

View File

@@ -6,7 +6,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::{EventUtils, BOOTSTRAP_RELAYS}; use common::EventUtils;
use device::DeviceRegistry; use device::DeviceRegistry;
use flume::Sender; use flume::Sender;
use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::skim::SkimMatcherV2;
@@ -16,7 +16,7 @@ use gpui::{
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{tracker, NostrAddress, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP};
mod message; mod message;
mod room; mod room;
@@ -365,56 +365,8 @@ impl ChatRegistry {
cx.notify(); cx.notify();
} }
/// Find for rooms that match the query. /// Finding rooms based on a query.
pub fn find(&self, query: &str, cx: &App) -> Task<Result<Vec<Entity<Room>>, Error>> { pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let http_client = cx.http_client();
let query = query.to_string();
if let Ok(addr) = Nip05Address::parse(&query) {
let task: Task<Result<PublicKey, Error>> = cx.background_spawn(async move {
let profile = addr.profile(&http_client).await?;
let public_key = profile.public_key;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(3)));
// Construct the filter for the metadata event
let filter = Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1);
// Subscribe to bootstrap relays
client
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts))
.await?;
Ok(public_key)
});
cx.spawn(async move |cx| {
let public_key = task.await?;
let results = cx.read_global::<GlobalChatRegistry, _>(|this, cx| {
this.0
.read_with(cx, |this, cx| this.find_rooms(&public_key.to_hex(), cx))
});
Ok(results)
})
} else {
cx.spawn(async move |cx| {
let results = cx.read_global::<GlobalChatRegistry, _>(|this, cx| {
this.0.read_with(cx, |this, cx| this.find_rooms(&query, cx))
});
Ok(results)
})
}
}
/// Internal find function for finding rooms based on a query.
fn find_rooms(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
let matcher = SkimMatcherV2::default(); let matcher = SkimMatcherV2::default();
if let Ok(public_key) = PublicKey::parse(query) { if let Ok(public_key) = PublicKey::parse(query) {
@@ -436,27 +388,6 @@ impl ChatRegistry {
} }
} }
/// Construct a chat room based on NIP-05 address.
pub fn address_to_room(&self, addr: Nip05Address, cx: &App) -> Task<Result<Room, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let http_client = cx.http_client();
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// Get the profile belonging to the address
let profile = addr.profile(&http_client).await?;
// Construct the room
let receivers = vec![profile.public_key];
let room = Room::new(None, public_key, receivers);
Ok(room)
})
}
/// Reset the registry. /// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) { pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear(); self.rooms.clear();

View File

@@ -167,22 +167,11 @@ impl From<&UnsignedEvent> for Room {
impl Room { impl Room {
/// Constructs a new room with the given receiver and tags. /// Constructs a new room with the given receiver and tags.
pub fn new(subject: Option<String>, author: PublicKey, receivers: Vec<PublicKey>) -> Self { pub fn new<T>(author: PublicKey, receivers: T) -> Self
// Convert receiver's public keys into tags where
let mut tags: Tags = Tags::from_list( T: IntoIterator<Item = PublicKey>,
receivers {
.iter() let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
.map(|pubkey| Tag::public_key(pubkey.to_owned()))
.collect(),
);
// Add subject if it is present
if let Some(subject) = subject {
tags.push(Tag::from_standardized_without_cell(TagStandard::Subject(
subject,
)));
}
let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "")
.tags(tags) .tags(tags)
.build(author); .build(author);

View File

@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::time::Duration; use std::time::Duration;
@@ -6,20 +7,36 @@ use chat::{ChatRegistry, Room};
use common::DebouncedDelay; use common::DebouncedDelay;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, point, px, uniform_list, AppContext, Bounds, Context, Entity, anchored, deferred, div, point, px, rems, uniform_list, App, AppContext, Bounds, Context,
Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Point, Render, Styled, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point,
Subscription, Task, Window, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
Task, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, FIND_DELAY}; use state::{NostrRegistry, FIND_DELAY};
use theme::{ActiveTheme, TITLEBAR_HEIGHT}; use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::{v_flex, window_paddings, IconName, Sizable, WindowExtension}; use ui::notification::Notification;
use ui::{h_flex, v_flex, window_paddings, Icon, IconName, Sizable, WindowExtension};
const WIDTH: Pixels = px(425.);
/// Command bar for searching conversations. /// Command bar for searching conversations.
pub struct CommandBar { pub struct CommandBar {
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Vec<PublicKey>>,
/// Whether to show the contact list
show_contact_list: bool,
/// Find input state /// Find input state
find_input: Entity<InputState>, find_input: Entity<InputState>,
@@ -30,17 +47,25 @@ pub struct CommandBar {
finding: bool, finding: bool,
/// Find results /// Find results
find_results: Entity<Option<Vec<Entity<Room>>>>, find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation /// Async find operation
find_task: Option<Task<Result<(), Error>>>, find_task: Option<Task<Result<(), Error>>>,
/// Image cache for avatars
image_cache: Entity<RetainAllImageCache>,
/// Async tasks
tasks: SmallVec<[Task<()>; 1]>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
} }
impl CommandBar { impl CommandBar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let selected_pkeys = cx.new(|_| HashSet::new());
let contact_list = cx.new(|_| vec![]);
let find_results = cx.new(|_| None); let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
@@ -71,21 +96,68 @@ impl CommandBar {
}); });
} }
} }
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {} _ => {}
}; };
}), }),
); );
Self { Self {
selected_pkeys,
contact_list,
show_contact_list: false,
find_debouncer: DebouncedDelay::new(), find_debouncer: DebouncedDelay::new(),
finding: false, finding: false,
find_input, find_input,
find_results, find_results,
find_task: None, find_task: None,
image_cache: RetainAllImageCache::new(cx),
tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_contact_list(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.extend_contacts(contacts, cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
}));
}
/// Extend the contact list with new contacts.
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
}
/// Toggle the visibility of the contact list.
fn toggle_contact_list(&mut self, cx: &mut Context<Self>) {
self.show_contact_list = !self.show_contact_list;
cx.notify();
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> { fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
@@ -96,15 +168,12 @@ impl CommandBar {
} }
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let query = self.find_input.read(cx).value();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity().read(cx).public_key(); let identity = nostr.read(cx).identity();
let query = self.find_input.read(cx).value();
// Return if the query is empty // Return if the query is empty
if query.is_empty() { if query.is_empty() {
log::warn!("Empty query");
return; return;
} }
@@ -122,36 +191,28 @@ impl CommandBar {
// Block the input until the search completes // Block the input until the search completes
self.set_finding(true, window, cx); self.set_finding(true, window, cx);
// Perform a local search let find_users = if identity.read(cx).owned {
let find_rooms = chat.read(cx).find(&query, cx); nostr.read(cx).wot_search(&query, cx)
// Perform a global search } else {
let find_users = nostr.read(cx).search(&query, cx); nostr.read(cx).search(&query, cx)
};
// Run task in the main thread // Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| { self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let local_rooms = find_rooms.await.ok().filter(|rooms| !rooms.is_empty()); let rooms = find_users.await?;
let rooms = match local_rooms { // Update the UI with the search results
Some(rooms) => rooms,
None => find_users
.await?
.into_iter()
.map(|event| cx.new(|_| Room::new(None, identity, vec![event.pubkey])))
.collect(),
};
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx); this.set_results(rooms, cx);
this.set_finding(false, window, cx); this.set_finding(false, window, cx);
}) })?;
.ok();
Ok(()) Ok(())
})); }));
} }
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) { fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| { self.find_results.update(cx, |this, cx| {
*this = Some(rooms); *this = Some(results);
cx.notify(); cx.notify();
}); });
} }
@@ -182,9 +243,46 @@ impl CommandBar {
cx.notify(); cx.notify();
} }
fn render_list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> { fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let receivers = self.selected(cx);
chat.update(cx, |this, cx| {
let room = cx.new(|_| Room::new(public_key, receivers));
this.emit_room(room.downgrade(), cx);
});
window.close_modal(cx);
}
fn select(&mut self, pkey: PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(&pkey) {
this.remove(&pkey);
} else {
this.insert(pkey);
}
cx.notify();
});
}
fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(&pkey)
}
fn selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let Some(rooms) = self.find_results.read(cx) else { let Some(rooms) = self.find_results.read(cx) else {
return vec![div().into_any_element()]; return vec![];
}; };
rooms rooms
@@ -193,11 +291,106 @@ impl CommandBar {
.flatten() .flatten()
.enumerate() .enumerate()
.map(|(ix, item)| { .map(|(ix, item)| {
let room = item.read(cx); let profile = persons.read(cx).get(item, cx);
let pkey = item.to_owned();
let id = range.start + ix;
div() h_flex()
.id(range.start + ix) .id(id)
.child(room.display_name(cx)) .h_8()
.w_full()
.px_1()
.gap_2()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
)
})
.child(
h_flex()
.flex_1()
.justify_between()
.line_clamp(1)
.text_ellipsis()
.truncate()
.text_sm()
.child(profile.name())
.when(self.is_selected(pkey, cx), |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.select(pkey, cx);
}))
.into_any_element()
})
.collect()
}
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let contacts = self.contact_list.read(cx);
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, item)| {
let profile = persons.read(cx).get(item, cx);
let pkey = item.to_owned();
let id = range.start + ix;
h_flex()
.id(id)
.h_8()
.w_full()
.px_1()
.gap_2()
.rounded(cx.theme().radius)
.when(!hide_avatar, |this| {
this.child(
div()
.flex_shrink_0()
.size_6()
.rounded_full()
.overflow_hidden()
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
)
})
.child(
h_flex()
.flex_1()
.justify_between()
.line_clamp(1)
.text_ellipsis()
.truncate()
.text_sm()
.child(profile.name())
.when(self.is_selected(pkey, cx), |this| {
this.child(
Icon::new(IconName::CheckCircle)
.small()
.text_color(cx.theme().icon_accent),
)
}),
)
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.select(pkey, cx);
}))
.into_any_element() .into_any_element()
}) })
.collect() .collect()
@@ -218,26 +411,34 @@ impl Render for CommandBar {
size: view_size, size: view_size,
}; };
let x = bounds.center().x - px(320.) / 2.; let x = bounds.center().x - WIDTH / 2.;
let y = TITLEBAR_HEIGHT; let y = TITLEBAR_HEIGHT;
let input_focus_handle = self.find_input.read(cx).focus_handle(cx); let input_focus_handle = self.find_input.read(cx).focus_handle(cx);
let input_focused = input_focus_handle.is_focused(window); let input_focused = input_focus_handle.is_focused(window);
let results = self.find_results.read(cx).as_ref(); let results = self.find_results.read(cx).as_ref();
let total_results = results.map_or(0, |r| r.len());
let contacts = self.contact_list.read(cx);
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
div() div()
.image_cache(self.image_cache.clone())
.w_full() .w_full()
.child( .child(
TextInput::new(&self.find_input) TextInput::new(&self.find_input)
.cleanable()
.appearance(true) .appearance(true)
.bordered(false) .bordered(false)
.xsmall() .xsmall()
.text_xs() .text_xs()
.when(!self.find_input.read(cx).loading, |this| { .when(!self.find_input.read(cx).loading, |this| {
this.suffix( this.suffix(
Button::new("find") Button::new("find-icon")
.icon(IconName::Search) .icon(IconName::Search)
.tooltip("Press Enter to search") .tooltip("Press Enter to search")
.transparent() .transparent()
@@ -265,8 +466,9 @@ impl Render for CommandBar {
.relative() .relative()
.left(x) .left(x)
.top(y) .top(y)
.w(px(320.)) .w(WIDTH)
.min_h_24() .min_h_24()
.overflow_y_hidden()
.p_1() .p_1()
.gap_1() .gap_1()
.justify_between() .justify_between()
@@ -275,30 +477,99 @@ impl Render for CommandBar {
.bg(cx.theme().surface_background) .bg(cx.theme().surface_background)
.shadow_md() .shadow_md()
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
.when_some(results, |this, results| { .map(|this| {
this.child( if self.show_contact_list {
uniform_list( this.child(
"rooms", uniform_list(
results.len(), "contacts",
cx.processor(|this, range, _window, cx| { contacts.len(),
this.render_list_items(range, cx) cx.processor(|this, range, _window, cx| {
}), this.render_contacts(range, cx)
}),
)
.when(!contacts.is_empty(), |this| this.h_40()),
) )
.h_32(), .when(contacts.is_empty(), |this| {
) this.child(
h_flex()
.h_10()
.w_full()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Your contact list is empty",
)),
)
})
} else {
this.child(
uniform_list(
"rooms",
total_results,
cx.processor(|this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.when(total_results > 0, |this| this.h_40()),
)
.when(total_results == 0, |this| {
this.child(
h_flex()
.h_10()
.w_full()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(
"Search results appear here",
)),
)
})
}
}) })
.child( .child(
v_flex() h_flex()
.pt_1() .pt_1()
.border_t_1() .border_t_1()
.border_color(cx.theme().border_variant) .border_color(cx.theme().border_variant)
.justify_end()
.child( .child(
Button::new("directory") Button::new("show-contacts")
.icon(IconName::Door) .label({
.label("Nostr Directory") if self.show_contact_list {
"Hide contact list"
} else {
"Show contact list"
}
})
.ghost() .ghost()
.xsmall() .xsmall()
.no_center(), .on_click(cx.listener(
move |this, _ev, _window, cx| {
this.toggle_contact_list(cx);
},
)),
)
.when(
!self.selected_pkeys.read(cx).is_empty(),
|this| {
this.child(
Button::new("create")
.label(button_label)
.primary()
.xsmall()
.on_click(cx.listener(
move |this, _ev, window, cx| {
this.create(window, cx);
},
)),
)
},
), ),
), ),
), ),

View File

@@ -1,3 +1 @@
pub mod compose;
pub mod profile;
pub mod screening; pub mod screening;

View File

@@ -19,5 +19,7 @@ flume.workspace = true
log.workspace = true log.workspace = true
anyhow.workspace = true anyhow.workspace = true
webbrowser.workspace = true webbrowser.workspace = true
serde.workspace = true
serde_json.workspace = true
rustls = "0.23" rustls = "0.23"

View File

@@ -3,7 +3,7 @@ use std::os::unix::fs::PermissionsExt;
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, Error}; use anyhow::{anyhow, Error};
use common::{config_dir, BOOTSTRAP_RELAYS, CLIENT_NAME, SEARCH_RELAYS}; use common::{config_dir, CLIENT_NAME};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use nostr_lmdb::NostrLmdb; use nostr_lmdb::NostrLmdb;
@@ -37,6 +37,17 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";
/// Default subscription id for user gift wrap events /// Default subscription id for user gift wrap events
pub const USER_GIFTWRAP: &str = "user-gift-wraps"; pub const USER_GIFTWRAP: &str = "user-gift-wraps";
/// Default vertex relays
pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"];
/// Default search relays
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nos.social",
"wss://user.kindpag.es",
];
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
// Initialize the tokio runtime // Initialize the tokio runtime
@@ -214,6 +225,11 @@ impl NostrRegistry {
client.add_relay(url).await?; client.add_relay(url).await?;
} }
// Add wot relay to the relay pool
for url in WOT_RELAYS.into_iter() {
client.add_relay(url).await?;
}
// Connect to all added relays // Connect to all added relays
client.connect().await; client.connect().await;
@@ -647,6 +663,19 @@ impl NostrRegistry {
})); }));
} }
/// Get contact list for the current user
pub fn get_contact_list(&self, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let public_key = self.identity().read(cx).public_key();
cx.background_spawn(async move {
let contacts = client.database().contacts_public_keys(public_key).await?;
let results = contacts.into_iter().collect();
Ok(results)
})
}
/// Set the metadata for the current user /// Set the metadata for the current user
pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> { pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let client = self.client(); let client = self.client();
@@ -806,13 +835,41 @@ impl NostrRegistry {
(signer, uri) (signer, uri)
} }
/// Get the public key of a NIP-05 address
pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task<Result<PublicKey, Error>> {
let client = self.client();
let http_client = cx.http_client();
cx.background_spawn(async move {
let profile = addr.profile(&http_client).await?;
let public_key = profile.public_key;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(3)));
// Construct the filter for the metadata event
let filter = Filter::new()
.kind(Kind::Metadata)
.author(public_key)
.limit(1);
// Subscribe to bootstrap relays
client
.subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts))
.await?;
Ok(public_key)
})
}
/// Perform a NIP-50 global search for user profiles based on a given query /// Perform a NIP-50 global search for user profiles based on a given query
pub fn search(&self, query: &str, cx: &App) -> Task<Result<Vec<Event>, Error>> { pub fn search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client(); let client = self.client();
let query = query.to_string(); let query = query.to_string();
cx.background_spawn(async move { cx.background_spawn(async move {
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT); let mut results: Vec<PublicKey> = Vec::with_capacity(FIND_LIMIT);
// Construct the filter for the search query // Construct the filter for the search query
let filter = Filter::new() let filter = Filter::new()
@@ -828,7 +885,7 @@ impl NostrRegistry {
// Collect the results // Collect the results
while let Some((_url, res)) = stream.next().await { while let Some((_url, res)) = stream.next().await {
if let Ok(event) = res { if let Ok(event) = res {
results.push(event); results.push(event.pubkey);
} }
} }
@@ -839,6 +896,64 @@ impl NostrRegistry {
Ok(results) Ok(results)
}) })
} }
/// Perform a WoT (via Vertex) search for a given query.
pub fn wot_search(&self, query: &str, cx: &App) -> Task<Result<Vec<PublicKey>, Error>> {
let client = self.client();
let query = query.to_string();
cx.background_spawn(async move {
let signer = client.signer().await?;
// Construct a vertex request event
let event = EventBuilder::new(Kind::Custom(5315), "")
.tags(vec![
Tag::custom(TagKind::custom("param"), vec!["search", &query]),
Tag::custom(TagKind::custom("param"), vec!["limit", "10"]),
])
.sign(&signer)
.await?;
// Send the event to vertex relays
let output = client.send_event_to(WOT_RELAYS, &event).await?;
// Construct a filter to get the response or error from vertex
let filter = Filter::new()
.kinds(vec![Kind::Custom(6315), Kind::Custom(7000)])
.event(output.id().to_owned());
// Stream events from the search relays
let mut stream = client
.stream_events_from(WOT_RELAYS, vec![filter], Duration::from_secs(3))
.await?;
while let Some((_url, res)) = stream.next().await {
if let Ok(event) = res {
match event.kind {
Kind::Custom(6315) => {
let content: serde_json::Value = serde_json::from_str(&event.content)?;
let pubkeys: Vec<PublicKey> = content
.as_array()
.into_iter()
.flatten()
.filter_map(|item| item.as_object())
.filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str()))
.filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok())
.collect();
return Ok(pubkeys);
}
Kind::Custom(7000) => {
return Err(anyhow!("Search error"));
}
_ => {}
}
}
}
Err(anyhow!("No results for query: {query}"))
})
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]