This commit is contained in:
2026-09-18 15:17:42 +07:00
parent 0ad491cb92
commit e75b1b9f10
4 changed files with 761 additions and 627 deletions
+485 -8
View File
@@ -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<SearchPanel> {
cx.new(|cx| SearchPanel::new(window, cx))
@@ -13,15 +33,360 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
pub struct SearchPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 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>) -> 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<Self>) {
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<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = 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<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
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<Self>) -> 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<Self>) {
// 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<PublicKey>, cx: &mut Context<Self>) {
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<Self>) {
// 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<Self>) {
// 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>) {
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<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
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<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
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<Self>) -> 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);
})),
),
)
})
}
}
+243 -614
View File
@@ -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<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Whether the find input is focused
find_focused: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Whether there are search results
has_search: bool,
/// Whether there are new chat requests
new_requests: bool,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// Expanded tree sections
expanded: BTreeSet<TreeSection>,
/// Chatroom filter
filter: Entity<RoomKind>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Pinned room ids, in pin order
pinned_rooms: Vec<u64>,
/// 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>) -> 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<Self>) {
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<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = 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<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
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<Self>) -> 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<Self>) {
// 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<Self>) {
if !self.expanded.remove(&section) {
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<PublicKey>, cx: &mut Context<Self>) {
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<Self>) {
// 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>) {
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<Self>) {
// 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>) {
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(&section)
}
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>) {
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>) {
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<SidebarRow> {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let mut rows = Vec::new();
let pinned: Vec<Entity<Room>> = 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<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) -> 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>) {
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<usize>,
rows: &[SidebarRow],
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(self.filter.read(cx), cx);
rooms
.get(range.clone())
) -> Vec<AnyElement> {
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<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
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<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
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()
+18
View File
@@ -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 {