wip
This commit is contained in:
@@ -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::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
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 theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||||
cx.new(|cx| SearchPanel::new(window, cx))
|
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 {
|
pub struct SearchPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
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 {
|
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 {
|
Self {
|
||||||
name: "Search".into(),
|
name: "Search".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
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 {
|
impl Panel for SearchPanel {
|
||||||
@@ -52,11 +417,123 @@ impl Focusable for SearchPanel {
|
|||||||
|
|
||||||
impl Render for SearchPanel {
|
impl Render for SearchPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
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()
|
.size_full()
|
||||||
.justify_center()
|
.gap_3()
|
||||||
.text_sm()
|
.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)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(self.name.clone())
|
.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);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+241
-612
@@ -1,34 +1,28 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::BTreeSet;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, TimestampExt};
|
use common::TimestampExt;
|
||||||
use entry::RoomEntry;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task,
|
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||||
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
|
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use instant::Duration;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::{FIND_DELAY, NostrRegistry};
|
use state::NostrRegistry;
|
||||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
|
||||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{
|
use ui::{
|
||||||
Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
|
IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, v_flex,
|
||||||
title_bar_drag_handlers, v_flex,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::Command;
|
use crate::Command;
|
||||||
@@ -36,48 +30,22 @@ use crate::Command;
|
|||||||
mod entry;
|
mod entry;
|
||||||
mod tree;
|
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.
|
/// Sidebar.
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
scroll_handle: UniformListScrollHandle,
|
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
|
/// Whether there are new chat requests
|
||||||
new_requests: bool,
|
new_requests: bool,
|
||||||
|
|
||||||
/// Selected public keys
|
/// Expanded tree sections
|
||||||
selected_pkeys: Entity<HashSet<PublicKey>>,
|
expanded: BTreeSet<TreeSection>,
|
||||||
|
|
||||||
/// Chatroom filter
|
/// Pinned room ids, in pin order
|
||||||
filter: Entity<RoomKind>,
|
pinned_rooms: Vec<u64>,
|
||||||
|
|
||||||
/// User's contacts
|
|
||||||
contact_list: Entity<Option<Vec<PublicKey>>>,
|
|
||||||
|
|
||||||
/// Async tasks
|
|
||||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
@@ -86,48 +54,9 @@ pub struct Sidebar {
|
|||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let chat = ChatRegistry::global(cx);
|
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![];
|
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(
|
subscriptions.push(
|
||||||
// Subscribe for registry new events
|
// Subscribe for registry new events
|
||||||
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| {
|
||||||
@@ -141,356 +70,217 @@ impl Sidebar {
|
|||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
scroll_handle: UniformListScrollHandle::new(),
|
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,
|
new_requests: false,
|
||||||
contact_list,
|
expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]),
|
||||||
selected_pkeys,
|
pinned_rooms: Vec::new(),
|
||||||
filter,
|
|
||||||
tasks: smallvec![],
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the contact list.
|
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>) {
|
||||||
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
if !self.expanded.remove(§ion) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
self.expanded.insert(section);
|
||||||
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.
|
if section == TreeSection::Requests {
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
});
|
|
||||||
this.emit_room(&room, _window, cx);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
self.new_requests = false;
|
self.new_requests = false;
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_list_items(
|
cx.notify();
|
||||||
&self,
|
}
|
||||||
range: Range<usize>,
|
|
||||||
cx: &Context<Self>,
|
fn is_expanded(&self, section: TreeSection) -> bool {
|
||||||
) -> Vec<impl IntoElement + use<>> {
|
self.expanded.contains(§ion)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ChatRegistry::global(cx);
|
||||||
let rooms = chat.read(cx).rooms(self.filter.read(cx), cx);
|
let chat = chat.read(cx);
|
||||||
|
|
||||||
rooms
|
let mut rows = Vec::new();
|
||||||
.get(range.clone())
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = chat.rooms(&RoomKind::Request, cx);
|
||||||
|
rows.push(SidebarRow::Section {
|
||||||
|
section: TreeSection::Requests,
|
||||||
|
count: requests.len(),
|
||||||
|
});
|
||||||
|
if self.is_expanded(TreeSection::Requests) {
|
||||||
|
if requests.is_empty() {
|
||||||
|
rows.push(SidebarRow::Hint {
|
||||||
|
text: "No pending requests".into(),
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rows.extend(requests.into_iter().map(|room| {
|
||||||
|
let pinned = self.is_pinned(room.read(cx).id);
|
||||||
|
SidebarRow::Room {
|
||||||
|
room,
|
||||||
|
depth: 1,
|
||||||
|
pinned,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let communities = dummy_communities();
|
||||||
|
rows.push(SidebarRow::Section {
|
||||||
|
section: TreeSection::Community,
|
||||||
|
count: communities.len(),
|
||||||
|
});
|
||||||
|
if self.is_expanded(TreeSection::Community) {
|
||||||
|
rows.extend(
|
||||||
|
communities
|
||||||
|
.iter()
|
||||||
|
.map(|entry| SidebarRow::Community { entry, depth: 1 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_rows(
|
||||||
|
&self,
|
||||||
|
range: Range<usize>,
|
||||||
|
rows: &[SidebarRow],
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> Vec<AnyElement> {
|
||||||
|
rows.get(range.clone())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(ix, item)| {
|
.map(|(offset, row)| {
|
||||||
let room = item.read(cx);
|
let index = range.start + offset;
|
||||||
let room_clone = item.clone();
|
|
||||||
let public_key = room.display_member(cx).public_key();
|
match row {
|
||||||
let handler = cx.listener(move |_this, _ev, window, cx| {
|
SidebarRow::Section { section, count } => {
|
||||||
ChatRegistry::global(cx).update(cx, |s, cx| {
|
let section = *section;
|
||||||
s.emit_room(&room_clone, window, 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
RoomEntry::new(range.start + ix)
|
RoomEntry::new(index)
|
||||||
.name(room.display_name(cx))
|
.name(name)
|
||||||
.avatar(room.display_image(cx))
|
.avatar(avatar)
|
||||||
.public_key(public_key)
|
.public_key(public_key)
|
||||||
.kind(room.kind)
|
.kind(kind)
|
||||||
.created_at(room.created_at.to_ago())
|
.created_at(created_at)
|
||||||
|
.depth(*depth)
|
||||||
.on_click(handler)
|
.on_click(handler)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
SidebarRow::Community { entry, depth } => TreeRow::new(
|
||||||
/// Render the contact list
|
ElementId::NamedInteger("tree-row".into(), index as u64),
|
||||||
fn render_results(
|
TreeRowKind::Community,
|
||||||
&self,
|
entry.name,
|
||||||
range: Range<usize>,
|
)
|
||||||
cx: &Context<Self>,
|
.depth(*depth)
|
||||||
) -> Vec<impl IntoElement + use<>> {
|
.avatar(entry.name)
|
||||||
let persons = PersonRegistry::global(cx);
|
.into_any_element(),
|
||||||
|
SidebarRow::Hint { text, depth } => TreeRow::new(
|
||||||
// Get the contact list
|
ElementId::NamedInteger("tree-row".into(), index as u64),
|
||||||
let Some(results) = self.find_results.read(cx) else {
|
TreeRowKind::Hint,
|
||||||
return vec![];
|
text.clone(),
|
||||||
};
|
)
|
||||||
|
.depth(*depth)
|
||||||
// Map the contact list to a list of elements
|
.into_any_element(),
|
||||||
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()
|
.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 {
|
impl Panel for Sidebar {
|
||||||
fn panel_id(&self) -> SharedString {
|
fn panel_id(&self) -> SharedString {
|
||||||
"Sidebar".into()
|
"Sidebar".into()
|
||||||
@@ -608,17 +411,7 @@ impl Render for Sidebar {
|
|||||||
let logged_in = nostr.read(cx).current_user().is_some();
|
let logged_in = nostr.read(cx).current_user().is_some();
|
||||||
let loading = chat.read(cx).loading() && logged_in;
|
let loading = chat.read(cx).loading() && logged_in;
|
||||||
|
|
||||||
let total_rooms = chat.read(cx).count(self.filter.read(cx), cx);
|
let rows = Rc::new(self.tree_rows(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"
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("sidebar"))
|
.image_cache(retain_all("sidebar"))
|
||||||
@@ -626,182 +419,40 @@ impl Render for Sidebar {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.child(self.render_user(window, cx))
|
.child(self.render_user(window, cx))
|
||||||
.child(
|
.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()
|
|
||||||
.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);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(!show_find_panel && !loading && total_rooms == 0, |this| {
|
|
||||||
this.child(
|
|
||||||
div().w(SIDEBAR_WIDTH).px_2().child(
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.p_3()
|
.px_2()
|
||||||
.h_24()
|
.py_1()
|
||||||
.w_full()
|
.gap_1()
|
||||||
.border_2()
|
.child(nav_item(
|
||||||
.border_dashed()
|
"nav-inbox",
|
||||||
.border_color(cx.theme().border_variant)
|
IconName::Inbox,
|
||||||
.rounded(cx.theme().radius_lg)
|
"Inbox",
|
||||||
.items_center()
|
Command::ShowInbox,
|
||||||
.justify_center()
|
))
|
||||||
.text_center()
|
.child(nav_item(
|
||||||
.child(div().text_sm().font_semibold().child("No conversations"))
|
"nav-browse",
|
||||||
.child(
|
IconName::Compass,
|
||||||
div()
|
"Browse",
|
||||||
.text_xs()
|
Command::ShowBrowse,
|
||||||
.text_color(cx.theme().text_muted)
|
))
|
||||||
.child("Start a conversation with someone to get started."),
|
.child(nav_item(
|
||||||
),
|
"nav-search",
|
||||||
),
|
IconName::Search,
|
||||||
|
"Search",
|
||||||
|
Command::ShowSearch,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.gap_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(
|
.child(
|
||||||
uniform_list(
|
uniform_list(
|
||||||
"rooms",
|
"sidebar-tree",
|
||||||
results.len(),
|
rows.len(),
|
||||||
cx.processor(move |this, range, _window, cx| {
|
cx.processor(move |this, range, _window, cx| {
|
||||||
this.render_results(range, cx)
|
this.render_rows(range, rows.as_slice(), 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)
|
.track_scroll(&self.scroll_handle)
|
||||||
@@ -809,30 +460,8 @@ impl Render for Sidebar {
|
|||||||
.h_full()
|
.h_full()
|
||||||
.px_2(),
|
.px_2(),
|
||||||
)
|
)
|
||||||
.child(Scrollbar::vertical(&self.scroll_handle))
|
.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| {
|
.when(loading, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ pub enum TreeSection {
|
|||||||
Messages,
|
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.
|
/// One rendered tree row, in flattened order.
|
||||||
pub enum SidebarRow {
|
pub enum SidebarRow {
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# Sidebar tree redesign
|
# Sidebar tree redesign
|
||||||
|
|
||||||
Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions,
|
Status: steps 1-5 implemented. Search now lives in `panels/search.rs`; the
|
||||||
panel shells); step 5 (search relocation + sidebar render rewrite) not started.
|
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`),
|
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
|
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
|
`greeter.rs`); register them in `panels/mod.rs`; handle the commands in
|
||||||
`Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`.
|
`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.
|
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
|
handoff).** Move the search/select implementation out of `Sidebar` into
|
||||||
`panels/search.rs` (inventory in §7), wiring the input, results, contacts,
|
`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
|
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
|
tree list, scrollbar, `render_user`, loading pill), add
|
||||||
`expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`,
|
`expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`,
|
||||||
`set_filter`, and the sidebar's search state. The search workstream owns the
|
`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
|
- [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire
|
||||||
`pin_room`/`unpin_room`.
|
`pin_room`/`unpin_room`.
|
||||||
- [ ] **Step 7 — community section.** Render dummy entries and hint; add the
|
- [ ] **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
|
- [ ] **Step 8 (optional) — persistence.** Add
|
||||||
`#[serde(default)] pinned_rooms: Vec<u64>` (and optionally
|
`#[serde(default)] pinned_rooms: Vec<u64>` (and optionally
|
||||||
`expanded_sections: Vec<String>`) to `settings::Settings`, register accessors
|
`expanded_sections: Vec<String>`) to `settings::Settings`, register accessors
|
||||||
|
|||||||
Reference in New Issue
Block a user