diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 9dc087aa..6243c209 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -1,10 +1,30 @@ +use std::collections::HashSet; +use std::ops::Range; + +use anyhow::Error; +use chat::{ChatRegistry, Room, RoomKind}; +use common::DebouncedDelay; +use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Window, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, + uniform_list, }; +use instant::Duration; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use smallvec::{SmallVec, smallvec}; +use state::{FIND_DELAY, NostrRegistry}; use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::{Icon, IconName, Sizable, h_flex}; +use ui::input::{Input, InputEvent, InputState}; +use ui::notification::Notification; +use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +use crate::sidebar::RoomEntry; + +const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| SearchPanel::new(window, cx)) @@ -13,15 +33,360 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct SearchPanel { name: SharedString, focus_handle: FocusHandle, + + /// Find input state + find_input: Entity, + + /// Debounced delay for find input + find_debouncer: DebouncedDelay, + + /// Whether a search is in progress + finding: bool, + + /// Find results + find_results: Entity>>, + + /// Async find operation + find_task: Option>>, + + /// Selected public keys + selected_pkeys: Entity>, + + /// User's contacts + contact_list: Entity>>, + + /// Async tasks + tasks: SmallVec<[Task>; 1]>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, } impl SearchPanel { - fn new(_window: &mut Window, cx: &mut App) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let contact_list = cx.new(|_| None); + let selected_pkeys = cx.new(|_| HashSet::new()); + let find_results = cx.new(|_| None); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(INPUT_PLACEHOLDER) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to 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 results when input is empty + this.reset(window, cx); + } else { + // Run debounced search + this.find_debouncer + .fire_new(delay, window, cx, |this, window, cx| { + this.debounced_search(window, cx) + }); + } + } + InputEvent::Focus => { + this.get_contact_list(window, cx); + } + _ => {} + }; + }), + ); + Self { name: "Search".into(), focus_handle: cx.focus_handle(), + find_input, + find_debouncer: DebouncedDelay::new(), + find_results, + find_task: None, + finding: false, + contact_list, + selected_pkeys, + tasks: smallvec![], + _subscriptions: subscriptions, } } + + /// Get the contact list. + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: HashSet = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.set_contact_list(contacts, cx); + })?; + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).autohide(false), + cx, + ); + })?; + } + }; + + Ok(()) + })); + } + + /// Set the contact list with new contacts. + fn set_contact_list(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + *this = Some(contacts.into_iter().collect()); + cx.notify(); + }); + } + + /// Trigger the debounced search + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, window, cx| { + this.search(window, cx); + }) + .ok(); + }) + } + + /// Search + fn search(&mut self, window: &mut Window, cx: &mut Context) { + // Get query + let query = self.find_input.read(cx).value(); + + // Return if the query is empty + if query.is_empty() { + return; + } + + // Block the input until the search completes + self.set_finding(true, window, cx); + + // Create the search task + let nostr = NostrRegistry::global(cx); + let find_users = nostr.read(cx).search(&query, cx); + + // Run task in the main thread + self.find_task = Some(cx.spawn_in(window, async move |this, cx| { + let rooms = find_users.await?; + + // Update the UI with the search results + this.update_in(cx, |this, window, cx| { + this.set_results(rooms, cx); + this.set_finding(false, window, cx); + })?; + + Ok(()) + })); + } + + /// Set the results of the search + fn set_results(&mut self, results: Vec, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(results); + cx.notify(); + }); + } + + /// Set the finding status + fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_loading(status, window, cx); + }); + // Set the search status + self.finding = status; + cx.notify(); + } + + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + // Clear all search results + self.find_results.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + + // Clear all selected public keys + self.selected_pkeys.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; + cx.notify(); + } + + /// Select a public key in the search panel. + fn select(&mut self, public_key: &PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(public_key) { + this.remove(public_key); + } else { + this.insert(public_key.to_owned()); + } + cx.notify(); + }); + } + + /// Check if a public key is selected in the search panel. + fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(public_key) + } + + /// Get all selected public keys in the search panel. + fn get_selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + /// Create a new room + fn create_room(&mut self, window: &mut Window, cx: &mut Context) { + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + // Get all selected public keys + let receivers = self.get_selected(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Create a new room and emit it + async_chat.update_in(cx, |this, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + this.emit_room(&room, _window, cx); + })?; + + // Reset the find panel + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + })?; + + Ok(()) + })); + } + + /// Render the search results + fn render_results( + &self, + range: Range, + cx: &Context, + ) -> Vec> { + let persons = PersonRegistry::global(cx); + + // Get the results + let Some(results) = self.find_results.read(cx) else { + return vec![]; + }; + + // Map the results to a list of elements + results + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() + } + + /// Render the contact list + fn render_contacts( + &self, + range: Range, + cx: &Context, + ) -> Vec> { + let persons = PersonRegistry::global(cx); + + // Get the contact list + let Some(contacts) = self.contact_list.read(cx) else { + return vec![]; + }; + + // Map the contact list to a list of elements + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name().trim()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() + } } impl Panel for SearchPanel { @@ -52,11 +417,123 @@ impl Focusable for SearchPanel { impl Render for SearchPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() + let nostr = NostrRegistry::global(cx); + let chat = ChatRegistry::global(cx); + let logged_in = nostr.read(cx).current_user().is_some(); + let loading = chat.read(cx).loading() && logged_in; + + // Set button label based on total selected users + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; + + v_flex() .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_muted) - .child(self.name.clone()) + .gap_3() + .p_2() + .child( + h_flex().child( + Input::new(&self.find_input) + .small() + .text_xs() + .disabled(loading) + .when( + !self.find_input.read(cx).presentation().is_loading(), + |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }, + ), + ), + ) + .child( + v_flex() + .flex_1() + .gap_3() + .when_some(self.find_results.read(cx).as_ref(), |this, results| { + this.child( + v_flex() + .gap_1() + .flex_1() + .border_b_1() + .border_color(cx.theme().border_variant) + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown)) + .child("Results"), + ) + .child( + uniform_list( + "rooms", + results.len(), + cx.processor(move |this, range, _window, cx| { + this.render_results(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { + this.child( + v_flex() + .gap_1() + .flex_1() + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown).small()) + .child("Contacts"), + ) + .child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(|this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }), + ) + .when(!self.selected_pkeys.read(cx).is_empty(), |this| { + this.child( + div() + .absolute() + .bottom_2() + .left_0() + .h_9() + .w_full() + .px_4() + .child( + Button::new("create") + .label(button_label) + .primary() + .rounded() + .shadow_md() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.create_room(window, cx); + })), + ), + ) + }) } } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 02129c91..d1b98fcd 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,34 +1,28 @@ -use std::collections::HashSet; +use std::collections::BTreeSet; use std::ops::Range; +use std::rc::Rc; -use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, TimestampExt}; -use entry::RoomEntry; +use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; -use instant::Duration; -use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; -use state::{FIND_DELAY, NostrRegistry}; -use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; +use state::NostrRegistry; +use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::input::{Input, InputEvent, InputState}; use ui::menu::{DropdownMenu, PopupMenuItem}; -use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, - title_bar_drag_handlers, v_flex, + IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, v_flex, }; use crate::Command; @@ -36,48 +30,22 @@ use crate::Command; mod entry; mod tree; -const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; +pub(crate) use entry::RoomEntry; +use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; /// Sidebar. pub struct Sidebar { focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, - /// Find input state - find_input: Entity, - - /// Debounced delay for find input - find_debouncer: DebouncedDelay, - - /// Whether a search is in progress - finding: bool, - - /// Whether the find input is focused - find_focused: bool, - - /// Find results - find_results: Entity>>, - - /// Async find operation - find_task: Option>>, - - /// Whether there are search results - has_search: bool, - /// Whether there are new chat requests new_requests: bool, - /// Selected public keys - selected_pkeys: Entity>, + /// Expanded tree sections + expanded: BTreeSet, - /// Chatroom filter - filter: Entity, - - /// User's contacts - contact_list: Entity>>, - - /// Async tasks - tasks: SmallVec<[Task>; 1]>, + /// Pinned room ids, in pin order + pinned_rooms: Vec, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -86,48 +54,9 @@ pub struct Sidebar { impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); - let filter = cx.new(|_| RoomKind::Ongoing); - let contact_list = cx.new(|_| None); - let selected_pkeys = cx.new(|_| HashSet::new()); - let find_results = cx.new(|_| None); - let find_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(INPUT_PLACEHOLDER) - .clean_on_escape() - }); let mut subscriptions = smallvec![]; - subscriptions.push( - // Subscribe to 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 results when input is empty - this.reset(window, cx); - } else { - // Run debounced search - this.find_debouncer - .fire_new(delay, window, cx, |this, window, cx| { - this.debounced_search(window, cx) - }); - } - } - InputEvent::Focus => { - this.set_input_focus(true, window, cx); - this.get_contact_list(window, cx); - } - _ => {} - }; - }), - ); - subscriptions.push( // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { @@ -141,356 +70,217 @@ impl Sidebar { Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), - find_input, - find_debouncer: DebouncedDelay::new(), - find_results, - find_task: None, - find_focused: false, - finding: false, - has_search: false, new_requests: false, - contact_list, - selected_pkeys, - filter, - tasks: smallvec![], + expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]), + pinned_rooms: Vec::new(), _subscriptions: subscriptions, } } - /// Get the contact list. - fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - let filter = Filter::new() - .author(public_key) - .kind(Kind::ContactList) - .limit(1); - - let contacts: HashSet = client - .database() - .query(filter) - .await? - .into_iter() - .next() - .map(|event| event.tags.public_keys().collect()) - .unwrap_or_default(); - - Ok(contacts) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.set_contact_list(contacts, cx); - })?; - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification( - Notification::error(e.to_string()).autohide(false), - cx, - ); - })?; - } - }; - - Ok(()) - })); - } - - /// Set the contact list with new contacts. - fn set_contact_list(&mut self, contacts: I, cx: &mut Context) - where - I: IntoIterator, - { - self.contact_list.update(cx, |this, cx| { - *this = Some(contacts.into_iter().collect()); - cx.notify(); - }); - } - - /// Trigger the debounced search - fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| { - this.search(window, cx); - }) - .ok(); - }) - } - - /// Search - fn search(&mut self, window: &mut Window, cx: &mut Context) { - // Get query - let query = self.find_input.read(cx).value(); - - // Return if the query is empty - if query.is_empty() { - return; + fn toggle_section(&mut self, section: TreeSection, cx: &mut Context) { + if !self.expanded.remove(§ion) { + self.expanded.insert(section); } - // Block the input until the search completes - self.set_finding(true, window, cx); - - // Create the search task - let nostr = NostrRegistry::global(cx); - let find_users = nostr.read(cx).search(&query, cx); - - // Run task in the main thread - self.find_task = Some(cx.spawn_in(window, async move |this, cx| { - let rooms = find_users.await?; - - // Update the UI with the search results - this.update_in(cx, |this, window, cx| { - this.set_results(rooms, cx); - this.set_finding(false, window, cx); - })?; - - Ok(()) - })); - } - - /// Set the results of the search - fn set_results(&mut self, results: Vec, cx: &mut Context) { - self.find_results.update(cx, |this, cx| { - *this = Some(results); - cx.notify(); - }); - } - - /// Set the finding status - fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - // Disable the input to prevent duplicate requests - self.find_input.update(cx, |this, cx| { - this.set_loading(status, window, cx); - }); - // Set the search status - self.finding = status; - cx.notify(); - } - - /// Set the focus status of the input element. - fn set_input_focus(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.find_focused = status; - cx.notify(); - - // Focus to the input element - if !status { - window.focus_prev(cx); + if section == TreeSection::Requests { + self.new_requests = false; } - } - fn reset(&mut self, window: &mut Window, cx: &mut Context) { - // Clear all search results - self.find_results.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - - // Clear all selected public keys - self.selected_pkeys.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - - // Reset the search status - self.set_finding(false, window, cx); - - // Cancel the current search task - self.find_task = None; cx.notify(); } - /// Select a public key in the sidebar. - fn select(&mut self, public_key: &PublicKey, cx: &mut Context) { - self.selected_pkeys.update(cx, |this, cx| { - if this.contains(public_key) { - this.remove(public_key); - } else { - this.insert(public_key.to_owned()); + fn is_expanded(&self, section: TreeSection) -> bool { + self.expanded.contains(§ion) + } + + fn pin_room(&mut self, room_id: u64, cx: &mut Context) { + if !self.pinned_rooms.contains(&room_id) { + self.pinned_rooms.push(room_id); + } + self.expanded.insert(TreeSection::Pins); + cx.notify(); + } + + fn unpin_room(&mut self, room_id: u64, cx: &mut Context) { + self.pinned_rooms.retain(|id| *id != room_id); + cx.notify(); + } + + fn is_pinned(&self, room_id: u64) -> bool { + self.pinned_rooms.contains(&room_id) + } + + fn tree_rows(&self, cx: &App) -> Vec { + let chat = ChatRegistry::global(cx); + let chat = chat.read(cx); + + let mut rows = Vec::new(); + + let pinned: Vec> = self + .pinned_rooms + .iter() + .filter_map(|room_id| chat.room(room_id, cx)) + .filter_map(|room| room.upgrade()) + .collect(); + + if !pinned.is_empty() { + rows.push(SidebarRow::Section { + section: TreeSection::Pins, + count: pinned.len(), + }); + + if self.is_expanded(TreeSection::Pins) { + rows.extend(pinned.into_iter().map(|room| SidebarRow::Room { + room, + depth: 1, + pinned: true, + })); } - cx.notify(); + } + + let requests = chat.rooms(&RoomKind::Request, cx); + rows.push(SidebarRow::Section { + section: TreeSection::Requests, + count: requests.len(), }); - } - - /// Check if a public key is selected in the sidebar. - fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool { - self.selected_pkeys.read(cx).contains(public_key) - } - - /// Get all selected public keys in the sidebar. - fn get_selected(&self, cx: &Context) -> HashSet { - self.selected_pkeys.read(cx).clone() - } - - /// Create a new room - fn create_room(&mut self, window: &mut Window, cx: &mut Context) { - let chat = ChatRegistry::global(cx); - let async_chat = chat.downgrade(); - - let nostr = NostrRegistry::global(cx); - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - // Get all selected public keys - let receivers = self.get_selected(cx); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - // Create a new room and emit it - async_chat.update_in(cx, |this, _window, cx| { - let room = cx.new(|_| { - Room::new(public_key, receivers) - .organize(&public_key) - .kind(RoomKind::Ongoing) + if self.is_expanded(TreeSection::Requests) { + if requests.is_empty() { + rows.push(SidebarRow::Hint { + text: "No pending requests".into(), + depth: 1, }); - this.emit_room(&room, _window, cx); - })?; + } else { + rows.extend(requests.into_iter().map(|room| { + let pinned = self.is_pinned(room.read(cx).id); + SidebarRow::Room { + room, + depth: 1, + pinned, + } + })); + } + } - // Reset the find panel - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - })?; - - Ok(()) - })); - } - - /// Get the active filter. - fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { - self.filter.read(cx) == kind - } - - /// Set the active filter for the sidebar. - fn set_filter(&mut self, kind: RoomKind, window: &mut Window, cx: &mut Context) { - self.set_input_focus(false, window, cx); - self.filter.update(cx, |this, cx| { - *this = kind; - cx.notify(); + let communities = dummy_communities(); + rows.push(SidebarRow::Section { + section: TreeSection::Community, + count: communities.len(), }); - self.new_requests = false; + if self.is_expanded(TreeSection::Community) { + rows.extend( + communities + .iter() + .map(|entry| SidebarRow::Community { entry, depth: 1 }), + ); + } - // Reset search state when switching to inbox/requests - self.reset(window, cx); - - // Clear the find input value - self.find_input.update(cx, |this, cx| { - this.set_value("", window, cx); + let messages = chat.rooms(&RoomKind::Ongoing, cx); + rows.push(SidebarRow::Section { + section: TreeSection::Messages, + count: messages.len(), }); + if self.is_expanded(TreeSection::Messages) { + if messages.is_empty() { + rows.push(SidebarRow::Hint { + text: "No conversations yet".into(), + depth: 1, + }); + } else { + rows.extend(messages.into_iter().map(|room| { + let pinned = self.is_pinned(room.read(cx).id); + SidebarRow::Room { + room, + depth: 1, + pinned, + } + })); + } + } + + rows } - fn render_list_items( + fn render_rows( &self, range: Range, + rows: &[SidebarRow], cx: &Context, - ) -> Vec> { - let chat = ChatRegistry::global(cx); - let rooms = chat.read(cx).rooms(self.filter.read(cx), cx); - - rooms - .get(range.clone()) + ) -> Vec { + rows.get(range.clone()) .into_iter() .flatten() .enumerate() - .map(|(ix, item)| { - let room = item.read(cx); - let room_clone = item.clone(); - let public_key = room.display_member(cx).public_key(); - let handler = cx.listener(move |_this, _ev, window, cx| { - ChatRegistry::global(cx).update(cx, |s, cx| { - s.emit_room(&room_clone, window, cx); - }); - }); + .map(|(offset, row)| { + let index = range.start + offset; - RoomEntry::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() - } + match row { + SidebarRow::Section { section, count } => { + let section = *section; - /// Render the contact list - fn render_results( - &self, - range: Range, - cx: &Context, - ) -> Vec> { - let persons = PersonRegistry::global(cx); + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Section, + section.label(), + ) + .caret(if self.is_expanded(section) { + IconName::CaretDown + } else { + IconName::CaretRight + }) + .icon(section.icon()) + .count(*count) + .when( + section == TreeSection::Requests && self.new_requests, + |this| this.dot(), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_section(section, cx); + })) + .into_any_element() + } + SidebarRow::Room { + room, + depth, + pinned: _pinned, + } => { + let public_key = room.read(cx).display_member(cx).public_key(); + let name = room.read(cx).display_name(cx); + let avatar = room.read(cx).display_image(cx); + let kind = room.read(cx).kind; + let created_at = room.read(cx).created_at.to_ago(); + let room_clone = room.clone(); + let handler = cx.listener(move |_this, _event, window, cx| { + ChatRegistry::global(cx).update(cx, |chat, cx| { + chat.emit_room(&room_clone, window, cx); + }); + }); - // Get the contact list - let Some(results) = self.find_results.read(cx) else { - return vec![]; - }; - - // Map the contact list to a list of elements - results - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, public_key)| { - let selected = self.is_selected(public_key, cx); - let profile = persons.read(cx).get(public_key, cx); - let pkey_clone = public_key.to_owned(); - let handler = cx.listener(move |this, _ev, _window, cx| { - this.select(&pkey_clone, cx); - }); - - RoomEntry::new(range.start + ix) - .name(profile.name()) - .avatar(profile.avatar()) - .on_click(handler) - .selected(selected) - .into_any_element() - }) - .collect() - } - - /// Render the contact list - fn render_contacts( - &self, - range: Range, - cx: &Context, - ) -> Vec> { - let persons = PersonRegistry::global(cx); - - // Get the contact list - let Some(contacts) = self.contact_list.read(cx) else { - return vec![]; - }; - - // Map the contact list to a list of elements - contacts - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, public_key)| { - let selected = self.is_selected(public_key, cx); - let profile = persons.read(cx).get(public_key, cx); - let pkey_clone = public_key.to_owned(); - let handler = cx.listener(move |this, _ev, _window, cx| { - this.select(&pkey_clone, cx); - }); - - RoomEntry::new(range.start + ix) - .name(profile.name().trim()) - .avatar(profile.avatar()) - .on_click(handler) - .selected(selected) - .into_any_element() + RoomEntry::new(index) + .name(name) + .avatar(avatar) + .public_key(public_key) + .kind(kind) + .created_at(created_at) + .depth(*depth) + .on_click(handler) + .into_any_element() + } + SidebarRow::Community { entry, depth } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Community, + entry.name, + ) + .depth(*depth) + .avatar(entry.name) + .into_any_element(), + SidebarRow::Hint { text, depth } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Hint, + text.clone(), + ) + .depth(*depth) + .into_any_element(), + } }) .collect() } @@ -587,6 +377,19 @@ impl Sidebar { } } +fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Command) -> Button { + Button::new(id) + .icon(icon) + .label(label) + .ghost_alt() + .small() + .w_full() + .justify_start() + .on_click(move |_event, _window, cx| { + cx.dispatch_action(&command); + }) +} + impl Panel for Sidebar { fn panel_id(&self) -> SharedString { "Sidebar".into() @@ -608,17 +411,7 @@ impl Render for Sidebar { let logged_in = nostr.read(cx).current_user().is_some(); let loading = chat.read(cx).loading() && logged_in; - let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); - - // Whether the find panel should be shown - let show_find_panel = self.has_search || self.find_focused; - - // Set button label based on total selected users - let button_label = if self.selected_pkeys.read(cx).len() > 1 { - "Create Group DM" - } else { - "Create DM" - }; + let rows = Rc::new(self.tree_rows(cx)); v_flex() .image_cache(retain_all("sidebar")) @@ -626,213 +419,49 @@ impl Render for Sidebar { .gap_2() .child(self.render_user(window, cx)) .child( - h_flex().px_2().py_1().child( - Input::new(&self.find_input) - .small() - .text_xs() - .disabled(loading) - .when( - !self.find_input.read(cx).presentation().is_loading(), - |this| { - this.suffix( - Button::new("find-icon") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - }, - ), - ), - ) - .child( - h_flex() + v_flex() .px_2() - .gap_2() - .justify_center() - .when(show_find_panel, |this| { - this.child( - Button::new("search-results") - .icon(IconName::Search) - .tooltip("All search results") - .ghost_alt() - .font_semibold() - .flex_1() - .selected(true), - ) - }) - .child( - Button::new("all") - .map(|this| { - if self.current_filter(&RoomKind::Ongoing, cx) { - this.icon(IconName::InboxFill) - } else { - this.icon(IconName::Inbox) - } - }) - .when(!show_find_panel, |this| this.label("Inbox").small()) - .tooltip("All ongoing conversations") - .ghost_alt() - .font_semibold() - .flex_1() - .selected( - !show_find_panel && self.current_filter(&RoomKind::Ongoing, cx), - ) - .on_click(cx.listener(|this, _ev, window, cx| { - this.set_filter(RoomKind::Ongoing, window, cx); - })), - ) - .child( - Button::new("requests") - .map(|this| { - if self.current_filter(&RoomKind::Request, cx) { - this.icon(IconName::FistbumpFill) - } else { - this.icon(IconName::Fistbump) - } - }) - .when(!show_find_panel, |this| this.label("Requests").small()) - .tooltip("Incoming new conversations") - .ghost_alt() - .font_semibold() - .flex_1() - .selected( - !show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx), - ) - .when(self.new_requests, |this| { - this.child(div().size_1().rounded_full().bg(cx.theme().cursor)) - }) - .on_click(cx.listener(|this, _ev, window, cx| { - this.set_filter(RoomKind::default(), window, cx); - })), - ), + .py_1() + .gap_1() + .child(nav_item( + "nav-inbox", + IconName::Inbox, + "Inbox", + Command::ShowInbox, + )) + .child(nav_item( + "nav-browse", + IconName::Compass, + "Browse", + Command::ShowBrowse, + )) + .child(nav_item( + "nav-search", + IconName::Search, + "Search", + Command::ShowSearch, + )), ) - .when(!show_find_panel && !loading && total_rooms == 0, |this| { - this.child( - div().w(SIDEBAR_WIDTH).px_2().child( - 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("No conversations")) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Start a conversation with someone to get started."), - ), - ), - ) - }) .child( v_flex() .size_full() .flex_1() .gap_1() - .when(show_find_panel, |this| { - this.gap_3() - .when_some(self.find_results.read(cx).as_ref(), |this, results| { - this.child( - v_flex() - .gap_1() - .flex_1() - .border_b_1() - .border_color(cx.theme().border_variant) - .child( - h_flex() - .gap_0p5() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::ChevronDown)) - .child("Results"), - ) - .child( - uniform_list( - "rooms", - results.len(), - cx.processor(move |this, range, _window, cx| { - this.render_results(range, cx) - }), - ) - .flex_1() - .h_full(), - ), - ) - }) - .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { - this.child( - v_flex() - .gap_1() - .flex_1() - .child( - h_flex() - .gap_0p5() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::ChevronDown).small()) - .child("Contacts"), - ) - .child( - uniform_list( - "contacts", - contacts.len(), - cx.processor(|this, range, _window, cx| { - this.render_contacts(range, cx) - }), - ) - .flex_1() - .h_full(), - ), - ) - }) - }) - .when(!show_find_panel, |this| { - this.child( - uniform_list( - "rooms", - total_rooms, - cx.processor(|this, range, _window, cx| { - this.render_list_items(range, cx) - }), - ) - .track_scroll(&self.scroll_handle) - .flex_1() - .h_full() - .px_2(), + .child( + uniform_list( + "sidebar-tree", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), ) - .child(Scrollbar::vertical(&self.scroll_handle)) - }), + .track_scroll(&self.scroll_handle) + .flex_1() + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.scroll_handle)), ) - .when(!self.selected_pkeys.read(cx).is_empty(), |this| { - this.child( - div() - .absolute() - .bottom_2() - .left_0() - .h_9() - .w_full() - .px_4() - .child( - Button::new("create") - .label(button_label) - .primary() - .rounded() - .shadow_md() - .on_click(cx.listener(move |this, _ev, window, cx| { - this.create_room(window, cx); - })), - ), - ) - }) .when(loading, |this| { this.child( div() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index c972f464..f7c15a77 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -18,6 +18,24 @@ pub enum TreeSection { Messages, } +impl TreeSection { + pub fn label(self) -> &'static str { + match self { + Self::Pins => "Pinned", + Self::Requests => "Requests", + Self::Community => "Community", + Self::Messages => "Messages", + } + } + + pub fn icon(self) -> IconName { + match self { + Self::Pins | Self::Requests | Self::Community => IconName::Folder, + Self::Messages => IconName::Message, + } + } +} + /// One rendered tree row, in flattened order. pub enum SidebarRow { Section { diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 264dd551..788eda71 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,7 +1,10 @@ # Sidebar tree redesign -Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions, -panel shells); step 5 (search relocation + sidebar render rewrite) not started. +Status: steps 1-5 implemented. Search now lives in `panels/search.rs`; the +sidebar renders the nav rail and the flattened tree. Remaining: step 6 (pin UI), +step 7 (community rows are already rendered from dummy data, tracked by the +`TODO(concord)`), optional step 8 (persistence), step 9 (cleanup of the step-6 +dead code). Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -318,7 +321,7 @@ unused until step 5 consumes them. Run the checks in §15 after each step. `greeter.rs`); register them in `panels/mod.rs`; handle the commands in `Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`. All three render empty bodies for now; the Search body is filled in step 5. -- [ ] **Step 5 — relocation + render rewrite (atomic, separate workstream +- [x] **Step 5 — relocation + render rewrite (atomic, separate workstream handoff).** Move the search/select implementation out of `Sidebar` into `panels/search.rs` (inventory in §7), wiring the input, results, contacts, selection, and create-DM button exactly as they are today; at the same time @@ -326,11 +329,18 @@ unused until step 5 consumes them. Run the checks in §15 after each step. tree list, scrollbar, `render_user`, loading pill), add `expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`, `set_filter`, and the sidebar's search state. The search workstream owns the - relocated module afterwards. + relocated module afterwards. Done: `SearchPanel` owns the input, debounce, + results, contacts, selection and create-DM flow; `Sidebar` owns + `expanded`/`pinned_rooms` and flattens the four sections into one + `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` + were dropped because they only existed to switch the sidebar between the room + list and the search view. - [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire `pin_room`/`unpin_room`. - [ ] **Step 7 — community section.** Render dummy entries and hint; add the - `TODO(concord)` marker. + `TODO(concord)` marker. The flattening and rendering landed with step 5 + (`SidebarRow::Community` -> `TreeRow`, dummy data from `dummy_communities()`), + so this step is effectively complete once the names in §10 are confirmed. - [ ] **Step 8 (optional) — persistence.** Add `#[serde(default)] pinned_rooms: Vec` (and optionally `expanded_sections: Vec`) to `settings::Settings`, register accessors