Files
coop/crates/coop/src/sidebar/mod.rs
reya 5d4481fb5f
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m48s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m37s
.
2026-01-28 14:11:13 +07:00

722 lines
26 KiB
Rust

use std::ops::Range;
use std::time::Duration;
use anyhow::{anyhow, Error};
use chat::{ChatEvent, ChatRegistry, Room};
use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
deferred, div, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache,
SharedString, Styled, Subscription, Task, Window,
};
use gpui_tokio::Tokio;
use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{smallvec, SmallVec};
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt, WindowExtension};
use crate::actions::{RelayStatus, Reload};
use crate::dialogs::compose::compose_button;
mod list_item;
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 20;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
cx.new(|cx| Sidebar::new(window, cx))
}
/// Sidebar.
pub struct Sidebar {
name: SharedString,
/// Focus handle for the sidebar
focus_handle: FocusHandle,
/// Image cache
image_cache: Entity<RetainAllImageCache>,
/// Search results
search_results: Entity<Option<Vec<Entity<Room>>>>,
/// Async search operation
search_task: Option<Task<()>>,
/// Search input state
find_input: Entity<InputState>,
/// Debounced delay for search input
find_debouncer: DebouncedDelay<Self>,
/// Whether searching is in progress
finding: bool,
/// New request flag
new_request: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let search_results = cx.new(|_| None);
// Define the find input state
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Find or start a conversation")
.clean_on_escape()
});
// Get the chat registry
let chat = ChatRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe for registry new events
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
if event == &ChatEvent::Ping {
this.new_request = true;
cx.notify();
};
}),
);
subscriptions.push(
// Subscribe for find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear the result when input is empty
this.clear(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
_ => {}
};
}),
);
Self {
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
new_request: false,
find_input,
search_results,
search_task: None,
_subscriptions: subscriptions,
}
}
async fn nip50(client: &Client, query: &str) -> Result<Vec<Event>, Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::Metadata)
.search(query.to_lowercase())
.limit(FIND_LIMIT);
let mut stream = client
.stream_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
.await?;
let mut results: Vec<Event> = Vec::with_capacity(FIND_LIMIT);
while let Some((_url, event)) = stream.next().await {
if let Ok(event) = event {
// Skip if author is match current user
if event.pubkey == public_key {
continue;
}
// Skip if the event has already been added
if results.iter().any(|this| this.pubkey == event.pubkey) {
continue;
}
results.push(event);
}
}
if results.is_empty() {
return Err(anyhow!("No results for query {query}"));
}
// Get all public keys
let public_keys: Vec<PublicKey> = results.iter().map(|event| event.pubkey).collect();
// Fetch metadata and contact lists if public keys is not empty
if !public_keys.is_empty() {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.limit(public_keys.len() * 2)
.authors(public_keys);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
}
Ok(results)
}
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let public_key = nostr.read(cx).identity().read(cx).public_key();
let query = query.to_owned();
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = Self::nip50(&client, &query).await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(results) => {
let rooms = results
.into_iter()
.map(|event| {
cx.new(|_| Room::new(None, public_key, vec![event.pubkey]))
})
.collect();
this.set_results(rooms, cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let address = query.to_owned();
let task = Tokio::spawn(cx, async move {
match common::nip05_profile(&address).await {
Ok(profile) => {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let receivers = vec![profile.public_key];
let room = Room::new(None, public_key, receivers);
Ok(room)
}
Err(e) => Err(anyhow!(e)),
}
});
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(room)) => {
this.set_results(vec![cx.new(|_| room)], cx);
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
}
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Ok(public_key) = query.to_public_key() else {
window.push_notification("Public Key is invalid", cx);
self.set_finding(false, window, cx);
return;
};
let task: Task<Result<Room, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let author = signer.get_public_key().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let receivers = vec![public_key];
let room = Room::new(None, author, receivers);
let filter = Filter::new()
.kinds(vec![Kind::Metadata, Kind::ContactList])
.author(public_key)
.limit(2);
client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await?;
Ok(room)
});
self.search_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(room) => {
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search_by_public_key(public_key, cx);
if !local_results.is_empty() {
this.set_results(local_results, cx);
} else {
this.set_results(vec![cx.new(|_| room)], cx);
}
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
this.set_finding(false, window, cx);
})
.ok();
}));
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if the query is empty
if self.find_input.read(cx).value().is_empty() {
return;
}
// Return if search is in progress
if self.finding {
if self.search_task.is_none() {
window.push_notification("There is another search in progress", cx);
return;
} else {
// Cancel ongoing search request
self.search_task = None;
}
}
let input = self.find_input.read(cx).value();
let query = input.to_string();
// Block the input until the search process completes
self.set_finding(true, window, cx);
// Process to search by pubkey if query starts with npub or nprofile
if query.starts_with("npub1") || query.starts_with("nprofile1") {
self.search_by_pubkey(&query, window, cx);
return;
};
// Process to search by NIP05 if query is a valid NIP-05 identifier (name@domain.tld)
if query.split('@').count() == 2 {
let parts: Vec<&str> = query.split('@').collect();
if !parts[0].is_empty() && !parts[1].is_empty() && parts[1].contains('.') {
self.search_by_nip05(&query, window, cx);
return;
}
}
// Get all local results with current query
let chat = ChatRegistry::global(cx);
let local_results = chat.read(cx).search(&query, cx);
// Try to update with local results first
if !local_results.is_empty() {
self.set_results(local_results, cx);
return;
};
// If no local results, try global search via NIP-50
self.search_by_nip50(&query, window, cx);
}
fn set_results(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
self.search_results.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
// Set the finding status
self.finding = status;
cx.notify();
}
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Reset the input state
if self.finding {
self.set_finding(false, window, cx);
}
// Clear all local results
self.search_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
}
fn open(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
match chat.read(cx).room(&id, cx) {
Some(room) => {
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
}
None => {
if let Some(room) = self
.search_results
.read(cx)
.as_ref()
.and_then(|results| results.iter().find(|this| this.read(cx).id == id))
.map(|this| this.downgrade())
{
chat.update(cx, |this, cx| {
this.emit_room(room, cx);
});
// Clear all search results
self.clear(window, cx);
}
}
}
}
fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context<Self>) {
ChatRegistry::global(cx).update(cx, |this, cx| {
this.get_rooms(cx);
});
window.push_notification("Reload", cx);
}
fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let subscription = client.subscription(&id).await;
let mut relays: Vec<Relay> = vec![];
for (url, _filter) in subscription.into_iter() {
relays.push(client.pool().relay(url).await?);
}
Ok(relays)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(relays) = task.await {
this.update_in(cx, |this, window, cx| {
this.manage_relays(relays, window, cx);
})
.ok();
}
})
.detach();
}
fn manage_relays(&mut self, relays: Vec<Relay>, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
this.show_close(true)
.overlay_closable(true)
.keyboard(true)
.title(SharedString::from("Messaging Relay Status"))
.child(v_flex().pb_4().gap_2().children({
let mut items = Vec::with_capacity(relays.len());
for relay in relays.clone().into_iter() {
let url = relay.url().to_string();
let time = relay.stats().connected_at().to_ago();
let connected = relay.is_connected();
items.push(
h_flex()
.h_8()
.px_2()
.justify_between()
.text_xs()
.bg(cx.theme().elevated_surface_background)
.rounded(cx.theme().radius)
.child(
h_flex()
.gap_1()
.font_semibold()
.child(
Icon::new(IconName::Relay)
.small()
.text_color(cx.theme().danger_active)
.when(connected, |this| {
this.text_color(gpui::green().alpha(0.75))
}),
)
.child(url),
)
.child(
div().text_right().text_color(cx.theme().text_muted).child(
SharedString::from(format!("Last activity: {}", time)),
),
),
);
}
items
}))
});
}
fn render_list_items(
&self,
range: Range<usize>,
_window: &Window,
cx: &Context<Self>,
) -> Vec<impl IntoElement> {
// Get rooms from search results first
let rooms = match self.search_results.read(cx).as_ref() {
Some(results) => results,
None => {
// Fallback to chat registry if there are no search results
let chat = ChatRegistry::global(cx);
chat.read(cx).rooms(cx)
}
};
rooms
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, item)| {
let room = item.read(cx);
let id = room.id;
let public_key = room.display_member(cx).public_key();
let handler = cx.listener({
move |this, _ev, window, cx| {
this.open(id, window, cx);
}
});
RoomListItem::new(range.start + ix)
.name(room.display_name(cx))
.avatar(room.display_image(cx))
.public_key(public_key)
.kind(room.kind)
.created_at(room.created_at.to_ago())
.on_click(handler)
.into_any_element()
})
.collect()
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let identity = nostr.read(cx).identity();
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
// Get total rooms
let total_rooms = match self.search_results.read(cx).as_ref() {
Some(results) => results.len(),
None => chat.read(cx).rooms(cx).len(),
};
v_flex()
.on_action(cx.listener(Self::on_reload))
.on_action(cx.listener(Self::on_manage))
.image_cache(self.image_cache.clone())
.size_full()
.relative()
.gap_2()
.bg(cx.theme().surface_background)
// Titlebar
.child(
h_flex()
.px_2p5()
.child(
h_flex()
.w_full()
.gap_2()
.justify_between()
.when_some(identity.read(cx).public_key, |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
this.child(
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(profile.avatar()).size(rems(1.25)))
.child(profile.name()),
)
})
.child(compose_button()),
)
.h(TITLEBAR_HEIGHT),
)
// Search Input
.child(
div().px_2p5().child(
TextInput::new(&self.find_input)
.small()
.cleanable()
.appearance(true)
.text_xs()
.flex_none()
.map(|this| {
if !self.find_input.read(cx).loading {
this.suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
} else {
this
}
}),
),
)
.when(!loading && total_rooms == 0, |this| {
this.child(
div().px_2p5().child(deferred(
v_flex()
.p_3()
.h_24()
.w_full()
.border_2()
.border_dashed()
.border_color(cx.theme().border_variant)
.rounded(cx.theme().radius_lg)
.items_center()
.justify_center()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.child(SharedString::from("No conversations")),
)
.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from(
"Start a conversation with someone to get started.",
),
)),
)),
)
})
// Chat Rooms
.child(
v_flex()
.px_1p5()
.w_full()
.flex_1()
.gap_1()
.overflow_y_hidden()
.child(
uniform_list(
"rooms",
total_rooms,
cx.processor(|this, range, window, cx| {
this.render_list_items(range, window, cx)
}),
)
.h_full(),
)
.when(loading, |this| {
this.child(
div().absolute().top_2().left_0().w_full().px_8().child(
h_flex()
.gap_2()
.w_full()
.h_9()
.justify_center()
.bg(cx.theme().background.opacity(0.85))
.border_color(cx.theme().border_disabled)
.border_1()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Indicator::new().small().color(cx.theme().icon_accent))
.child(SharedString::from("Getting messages...")),
),
)
}),
)
}
}