wip: command bar
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m47s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m58s
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m47s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m58s
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -6076,6 +6076,8 @@ dependencies = [
|
|||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"smol",
|
"smol",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(id)
|
||||||
|
.h_8()
|
||||||
|
.w_full()
|
||||||
|
.px_1()
|
||||||
|
.gap_2()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.when(!hide_avatar, |this| {
|
||||||
|
this.child(
|
||||||
div()
|
div()
|
||||||
.id(range.start + ix)
|
.flex_shrink_0()
|
||||||
.child(room.display_name(cx))
|
.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| {
|
||||||
|
if self.show_contact_list {
|
||||||
|
this.child(
|
||||||
|
uniform_list(
|
||||||
|
"contacts",
|
||||||
|
contacts.len(),
|
||||||
|
cx.processor(|this, range, _window, cx| {
|
||||||
|
this.render_contacts(range, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(!contacts.is_empty(), |this| this.h_40()),
|
||||||
|
)
|
||||||
|
.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(
|
this.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
"rooms",
|
"rooms",
|
||||||
results.len(),
|
total_results,
|
||||||
cx.processor(|this, range, _window, cx| {
|
cx.processor(|this, range, _window, cx| {
|
||||||
this.render_list_items(range, cx)
|
this.render_results(range, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.h_32(),
|
.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);
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
pub mod compose;
|
|
||||||
pub mod profile;
|
|
||||||
pub mod screening;
|
pub mod screening;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user