Continue redesign for the v1 stable release #5
@@ -177,7 +177,9 @@ impl Room {
|
|||||||
where
|
where
|
||||||
T: IntoIterator<Item = PublicKey>,
|
T: IntoIterator<Item = PublicKey>,
|
||||||
{
|
{
|
||||||
|
// Map receiver public keys to tags
|
||||||
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
|
let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect());
|
||||||
|
|
||||||
// Construct an unsigned event for a direct message
|
// Construct an unsigned event for a direct message
|
||||||
//
|
//
|
||||||
// WARNING: never sign this event
|
// WARNING: never sign this event
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
use gpui::actions;
|
|
||||||
|
|
||||||
// Sidebar actions
|
|
||||||
actions!(sidebar, [Reload, RelayStatus]);
|
|
||||||
|
|
||||||
// User actions
|
|
||||||
actions!(
|
|
||||||
coop,
|
|
||||||
[
|
|
||||||
KeyringPopup,
|
|
||||||
DarkMode,
|
|
||||||
ViewProfile,
|
|
||||||
ViewRelays,
|
|
||||||
Themes,
|
|
||||||
Settings,
|
|
||||||
Logout,
|
|
||||||
Quit
|
|
||||||
]
|
|
||||||
);
|
|
||||||
@@ -2,21 +2,20 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use assets::Assets;
|
use assets::Assets;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||||
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
|
SharedString, Size, TitlebarOptions, WindowBackgroundAppearance, WindowBounds,
|
||||||
WindowDecorations, WindowKind, WindowOptions,
|
WindowDecorations, WindowKind, WindowOptions,
|
||||||
};
|
};
|
||||||
use state::{APP_ID, CLIENT_NAME};
|
use state::{APP_ID, CLIENT_NAME};
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
use crate::actions::Quit;
|
|
||||||
|
|
||||||
mod actions;
|
|
||||||
mod dialogs;
|
mod dialogs;
|
||||||
mod panels;
|
mod panels;
|
||||||
mod sidebar;
|
mod sidebar;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
|
actions!(coop, [Quit]);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|||||||
@@ -1,586 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::ops::Range;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use chat::{ChatRegistry, Room};
|
|
||||||
use common::DebouncedDelay;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
anchored, deferred, div, point, px, rems, uniform_list, App, AppContext, Bounds, Context,
|
|
||||||
Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point,
|
|
||||||
Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription,
|
|
||||||
Task, Window,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use person::PersonRegistry;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::{NostrRegistry, FIND_DELAY};
|
|
||||||
use theme::{ActiveTheme, TITLEBAR_HEIGHT};
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{h_flex, v_flex, window_paddings, Icon, IconName, Sizable, WindowExtension};
|
|
||||||
|
|
||||||
const WIDTH: Pixels = px(425.);
|
|
||||||
|
|
||||||
/// Command bar for searching conversations.
|
|
||||||
pub struct CommandBar {
|
|
||||||
/// Selected public keys
|
|
||||||
selected_pkeys: Entity<HashSet<PublicKey>>,
|
|
||||||
|
|
||||||
/// User's contacts
|
|
||||||
contact_list: Entity<Vec<PublicKey>>,
|
|
||||||
|
|
||||||
/// Whether to show the contact list
|
|
||||||
show_contact_list: bool,
|
|
||||||
|
|
||||||
/// Find input state
|
|
||||||
find_input: 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>>>,
|
|
||||||
|
|
||||||
/// Image cache for avatars
|
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
|
||||||
|
|
||||||
/// Async tasks
|
|
||||||
tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandBar {
|
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let selected_pkeys = cx.new(|_| HashSet::new());
|
|
||||||
let contact_list = cx.new(|_| vec![]);
|
|
||||||
let find_results = cx.new(|_| None);
|
|
||||||
let find_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.placeholder("Find or start a conversation")
|
|
||||||
.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 {
|
|
||||||
selected_pkeys,
|
|
||||||
contact_list,
|
|
||||||
show_contact_list: false,
|
|
||||||
find_debouncer: DebouncedDelay::new(),
|
|
||||||
finding: false,
|
|
||||||
find_input,
|
|
||||||
find_results,
|
|
||||||
find_task: None,
|
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
|
||||||
tasks: smallvec![],
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let task = nostr.read(cx).get_contact_list(cx);
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match task.await {
|
|
||||||
Ok(contacts) => {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.extend_contacts(contacts, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
cx.update(|window, cx| {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extend the contact list with new contacts.
|
|
||||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
I: IntoIterator<Item = PublicKey>,
|
|
||||||
{
|
|
||||||
self.contact_list.update(cx, |this, cx| {
|
|
||||||
this.extend(contacts);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle the visibility of the contact list.
|
|
||||||
fn toggle_contact_list(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.show_contact_list = !self.show_contact_list;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.search(window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let query = self.find_input.read(cx).value();
|
|
||||||
|
|
||||||
// Return if the query is empty
|
|
||||||
if query.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return if a search is already in progress
|
|
||||||
if self.finding {
|
|
||||||
if self.find_task.is_none() {
|
|
||||||
window.push_notification("There is another search in progress", cx);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// Cancel the ongoing search request
|
|
||||||
self.find_task = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block the input until the search completes
|
|
||||||
self.set_finding(true, window, 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(())
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
|
|
||||||
self.find_results.update(cx, |this, cx| {
|
|
||||||
*this = Some(results);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
// Disable the input to prevent duplicate requests
|
|
||||||
self.find_input.update(cx, |this, cx| {
|
|
||||||
this.set_disabled(status, cx);
|
|
||||||
this.set_loading(status, cx);
|
|
||||||
});
|
|
||||||
// Set the 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset the search status
|
|
||||||
self.set_finding(false, window, cx);
|
|
||||||
|
|
||||||
// Cancel the current search task
|
|
||||||
self.find_task = None;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&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 signer_pkey = nostr.read(cx).signer_pkey(cx);
|
|
||||||
|
|
||||||
// Get all selected public keys
|
|
||||||
let receivers = self.selected(cx);
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let public_key = signer_pkey.await?;
|
|
||||||
|
|
||||||
async_chat.update_in(cx, |this, window, cx| {
|
|
||||||
let room = cx.new(|_| Room::new(public_key, receivers));
|
|
||||||
this.emit_room(room.downgrade(), cx);
|
|
||||||
|
|
||||||
window.close_modal(cx);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select(&mut self, pkey: PublicKey, cx: &mut Context<Self>) {
|
|
||||||
self.selected_pkeys.update(cx, |this, cx| {
|
|
||||||
if this.contains(&pkey) {
|
|
||||||
this.remove(&pkey);
|
|
||||||
} else {
|
|
||||||
this.insert(pkey);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool {
|
|
||||||
self.selected_pkeys.read(cx).contains(&pkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
|
|
||||||
self.selected_pkeys.read(cx).clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
|
||||||
|
|
||||||
let Some(rooms) = self.find_results.read(cx) else {
|
|
||||||
return vec![];
|
|
||||||
};
|
|
||||||
|
|
||||||
rooms
|
|
||||||
.get(range.clone())
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(ix, item)| {
|
|
||||||
let profile = persons.read(cx).get(item, cx);
|
|
||||||
let pkey = item.to_owned();
|
|
||||||
let id = range.start + ix;
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id(id)
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.px_1()
|
|
||||||
.gap_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(!hide_avatar, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.size_6()
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.justify_between()
|
|
||||||
.line_clamp(1)
|
|
||||||
.text_ellipsis()
|
|
||||||
.truncate()
|
|
||||||
.text_sm()
|
|
||||||
.child(profile.name())
|
|
||||||
.when(self.is_selected(pkey, cx), |this| {
|
|
||||||
this.child(
|
|
||||||
Icon::new(IconName::CheckCircle)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().icon_accent),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.select(pkey, cx);
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
|
||||||
let contacts = self.contact_list.read(cx);
|
|
||||||
|
|
||||||
contacts
|
|
||||||
.get(range.clone())
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(ix, item)| {
|
|
||||||
let profile = persons.read(cx).get(item, cx);
|
|
||||||
let pkey = item.to_owned();
|
|
||||||
let id = range.start + ix;
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.id(id)
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.px_1()
|
|
||||||
.gap_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(!hide_avatar, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.size_6()
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.justify_between()
|
|
||||||
.line_clamp(1)
|
|
||||||
.text_ellipsis()
|
|
||||||
.truncate()
|
|
||||||
.text_sm()
|
|
||||||
.child(profile.name())
|
|
||||||
.when(self.is_selected(pkey, cx), |this| {
|
|
||||||
this.child(
|
|
||||||
Icon::new(IconName::CheckCircle)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().icon_accent),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.select(pkey, cx);
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for CommandBar {
|
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let window_paddings = window_paddings(window, cx);
|
|
||||||
let view_size = window.viewport_size()
|
|
||||||
- gpui::size(
|
|
||||||
window_paddings.left + window_paddings.right,
|
|
||||||
window_paddings.top + window_paddings.bottom,
|
|
||||||
);
|
|
||||||
|
|
||||||
let bounds = Bounds {
|
|
||||||
origin: Point::default(),
|
|
||||||
size: view_size,
|
|
||||||
};
|
|
||||||
|
|
||||||
let x = bounds.center().x - WIDTH / 2.;
|
|
||||||
let y = TITLEBAR_HEIGHT;
|
|
||||||
|
|
||||||
let input_focus_handle = self.find_input.read(cx).focus_handle(cx);
|
|
||||||
let input_focused = input_focus_handle.is_focused(window);
|
|
||||||
|
|
||||||
let results = self.find_results.read(cx).as_ref();
|
|
||||||
let total_results = results.map_or(0, |r| r.len());
|
|
||||||
|
|
||||||
let contacts = self.contact_list.read(cx);
|
|
||||||
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
|
|
||||||
"Create Group DM"
|
|
||||||
} else {
|
|
||||||
"Create DM"
|
|
||||||
};
|
|
||||||
|
|
||||||
div()
|
|
||||||
.image_cache(self.image_cache.clone())
|
|
||||||
.w_full()
|
|
||||||
.child(
|
|
||||||
TextInput::new(&self.find_input)
|
|
||||||
.appearance(false)
|
|
||||||
.bordered(false)
|
|
||||||
.small()
|
|
||||||
.text_xs()
|
|
||||||
.when(!self.find_input.read(cx).loading, |this| {
|
|
||||||
this.suffix(
|
|
||||||
Button::new("find-icon")
|
|
||||||
.icon(IconName::Search)
|
|
||||||
.tooltip("Press Enter to search")
|
|
||||||
.transparent()
|
|
||||||
.small(),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(input_focused, |this| {
|
|
||||||
this.child(deferred(
|
|
||||||
anchored()
|
|
||||||
.position(point(window_paddings.left, window_paddings.top))
|
|
||||||
.snap_to_window()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.occlude()
|
|
||||||
.w(view_size.width)
|
|
||||||
.h(view_size.height)
|
|
||||||
.on_mouse_down(MouseButton::Left, move |_ev, window, cx| {
|
|
||||||
window.focus_prev(cx);
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.absolute()
|
|
||||||
.occlude()
|
|
||||||
.relative()
|
|
||||||
.left(x)
|
|
||||||
.top(y)
|
|
||||||
.w(WIDTH)
|
|
||||||
.min_h_24()
|
|
||||||
.overflow_y_hidden()
|
|
||||||
.p_1()
|
|
||||||
.gap_1()
|
|
||||||
.justify_between()
|
|
||||||
.border_1()
|
|
||||||
.border_color(cx.theme().border.alpha(0.4))
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.shadow_md()
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.map(|this| {
|
|
||||||
if self.show_contact_list {
|
|
||||||
this.child(
|
|
||||||
uniform_list(
|
|
||||||
"contacts",
|
|
||||||
contacts.len(),
|
|
||||||
cx.processor(|this, range, _window, cx| {
|
|
||||||
this.render_contacts(range, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(!contacts.is_empty(), |this| this.h_40()),
|
|
||||||
)
|
|
||||||
.when(contacts.is_empty(), |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_10()
|
|
||||||
.w_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.text_center()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Your contact list is empty",
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.child(
|
|
||||||
uniform_list(
|
|
||||||
"rooms",
|
|
||||||
total_results,
|
|
||||||
cx.processor(|this, range, _window, cx| {
|
|
||||||
this.render_results(range, cx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(total_results > 0, |this| this.h_40()),
|
|
||||||
)
|
|
||||||
.when(total_results == 0, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_10()
|
|
||||||
.w_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.text_center()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Search results appear here",
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.pt_1()
|
|
||||||
.border_t_1()
|
|
||||||
.border_color(cx.theme().border_variant)
|
|
||||||
.justify_end()
|
|
||||||
.child(
|
|
||||||
Button::new("show-contacts")
|
|
||||||
.label({
|
|
||||||
if self.show_contact_list {
|
|
||||||
"Hide contact list"
|
|
||||||
} else {
|
|
||||||
"Show contact list"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ghost()
|
|
||||||
.xsmall()
|
|
||||||
.on_click(cx.listener(
|
|
||||||
move |this, _ev, _window, cx| {
|
|
||||||
this.toggle_contact_list(cx);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.when(
|
|
||||||
!self.selected_pkeys.read(cx).is_empty(),
|
|
||||||
|this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("create")
|
|
||||||
.label(button_label)
|
|
||||||
.primary()
|
|
||||||
.xsmall()
|
|
||||||
.on_click(cx.listener(
|
|
||||||
move |this, _ev, window, cx| {
|
|
||||||
this.create(window, cx);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,23 +14,24 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::context_menu::ContextMenuExt;
|
use ui::context_menu::ContextMenuExt;
|
||||||
use ui::modal::ModalButtonProps;
|
use ui::modal::ModalButtonProps;
|
||||||
use ui::{h_flex, StyledExt, WindowExtension};
|
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
use crate::dialogs::screening;
|
use crate::dialogs::screening;
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct RoomListItem {
|
pub struct RoomEntry {
|
||||||
ix: usize,
|
ix: usize,
|
||||||
public_key: Option<PublicKey>,
|
public_key: Option<PublicKey>,
|
||||||
name: Option<SharedString>,
|
name: Option<SharedString>,
|
||||||
avatar: Option<SharedString>,
|
avatar: Option<SharedString>,
|
||||||
created_at: Option<SharedString>,
|
created_at: Option<SharedString>,
|
||||||
kind: Option<RoomKind>,
|
kind: Option<RoomKind>,
|
||||||
|
selected: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomListItem {
|
impl RoomEntry {
|
||||||
pub fn new(ix: usize) -> Self {
|
pub fn new(ix: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ix,
|
ix,
|
||||||
@@ -40,6 +41,7 @@ impl RoomListItem {
|
|||||||
created_at: None,
|
created_at: None,
|
||||||
kind: None,
|
kind: None,
|
||||||
handler: None,
|
handler: None,
|
||||||
|
selected: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +79,25 @@ impl RoomListItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for RoomListItem {
|
impl Selectable for RoomEntry {
|
||||||
|
fn selected(mut self, selected: bool) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for RoomEntry {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let screening = AppSettings::get_screening(cx);
|
let screening = AppSettings::get_screening(cx);
|
||||||
|
|
||||||
|
let public_key = self.public_key;
|
||||||
|
let is_selected = self.is_selected();
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.ix)
|
.id(self.ix)
|
||||||
.h_9()
|
.h_9()
|
||||||
@@ -110,13 +126,21 @@ impl RenderOnce for RoomListItem {
|
|||||||
.justify_between()
|
.justify_between()
|
||||||
.when_some(self.name, |this, name| {
|
.when_some(self.name, |this, name| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
h_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
.justify_between()
|
||||||
.line_clamp(1)
|
.line_clamp(1)
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.truncate()
|
.truncate()
|
||||||
.font_medium()
|
.font_medium()
|
||||||
.child(name),
|
.child(name)
|
||||||
|
.when(is_selected, |this| {
|
||||||
|
this.child(
|
||||||
|
Icon::new(IconName::CheckCircle)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().icon_accent),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
@@ -129,15 +153,17 @@ impl RenderOnce for RoomListItem {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||||
.when_some(self.public_key, |this, public_key| {
|
.when_some(public_key, |this, public_key| {
|
||||||
this.context_menu(move |this, _window, _cx| {
|
this.context_menu(move |this, _window, _cx| {
|
||||||
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
this.menu("View Profile", Box::new(OpenPublicKey(public_key)))
|
||||||
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
.menu("Copy Public Key", Box::new(CopyPublicKey(public_key)))
|
||||||
})
|
})
|
||||||
|
})
|
||||||
.when_some(self.handler, |this, handler| {
|
.when_some(self.handler, |this, handler| {
|
||||||
this.on_click(move |event, window, cx| {
|
this.on_click(move |event, window, cx| {
|
||||||
handler(event, window, cx);
|
handler(event, window, cx);
|
||||||
|
|
||||||
|
if let Some(public_key) = public_key {
|
||||||
if self.kind != Some(RoomKind::Ongoing) && screening {
|
if self.kind != Some(RoomKind::Ongoing) && screening {
|
||||||
let screening = screening::init(public_key, window, cx);
|
let screening = screening::init(public_key, window, cx);
|
||||||
|
|
||||||
@@ -152,12 +178,12 @@ impl RenderOnce for RoomListItem {
|
|||||||
.on_cancel(move |_event, window, cx| {
|
.on_cancel(move |_event, window, cx| {
|
||||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||||
// Prevent closing the modal on click
|
// Prevent closing the modal on click
|
||||||
// Modal will be automatically closed after closing panel
|
// modal will be automatically closed after closing panel
|
||||||
false
|
false
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -6,30 +6,26 @@ use anyhow::Error;
|
|||||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||||
use common::{DebouncedDelay, RenderedTimestamp};
|
use common::{DebouncedDelay, RenderedTimestamp};
|
||||||
use dock::panel::{Panel, PanelEvent};
|
use dock::panel::{Panel, PanelEvent};
|
||||||
|
use entry::RoomEntry;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, rems, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
div, uniform_list, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
Focusable, InteractiveElement, IntoElement, ParentElement, Render, RetainAllImageCache,
|
IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription,
|
||||||
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
|
Task, Window,
|
||||||
};
|
};
|
||||||
use list_item::RoomListItem;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use state::{NostrRegistry, FIND_DELAY};
|
use state::{NostrRegistry, FIND_DELAY};
|
||||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::divider::Divider;
|
use ui::divider::Divider;
|
||||||
use ui::indicator::Indicator;
|
use ui::indicator::Indicator;
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{
|
use ui::{h_flex, v_flex, Disableable, IconName, Selectable, Sizable, StyledExt, WindowExtension};
|
||||||
h_flex, v_flex, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod list_item;
|
mod entry;
|
||||||
|
|
||||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||||
|
|
||||||
@@ -264,9 +260,14 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_focus(&mut self, cx: &mut Context<Self>) {
|
fn set_input_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.find_focused = !self.find_focused;
|
self.find_focused = !self.find_focused;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
|
// Reset the find panel
|
||||||
|
if !self.find_focused {
|
||||||
|
self.reset(window, cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -276,6 +277,12 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear all selected public keys
|
||||||
|
self.selected_pkeys.update(cx, |this, cx| {
|
||||||
|
this.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
// Reset the search status
|
// Reset the search status
|
||||||
self.set_finding(false, window, cx);
|
self.set_finding(false, window, cx);
|
||||||
|
|
||||||
@@ -285,20 +292,20 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Select a public key in the sidebar.
|
/// Select a public key in the sidebar.
|
||||||
fn select(&mut self, pkey: PublicKey, cx: &mut Context<Self>) {
|
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
||||||
self.selected_pkeys.update(cx, |this, cx| {
|
self.selected_pkeys.update(cx, |this, cx| {
|
||||||
if this.contains(&pkey) {
|
if this.contains(public_key) {
|
||||||
this.remove(&pkey);
|
this.remove(public_key);
|
||||||
} else {
|
} else {
|
||||||
this.insert(pkey);
|
this.insert(public_key.to_owned());
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a public key is selected in the sidebar.
|
/// Check if a public key is selected in the sidebar.
|
||||||
fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool {
|
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
|
||||||
self.selected_pkeys.read(cx).contains(&pkey)
|
self.selected_pkeys.read(cx).contains(public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all selected public keys in the sidebar.
|
/// Get all selected public keys in the sidebar.
|
||||||
@@ -317,14 +324,18 @@ impl Sidebar {
|
|||||||
// Get all selected public keys
|
// Get all selected public keys
|
||||||
let receivers = self.selected(cx);
|
let receivers = self.selected(cx);
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |_this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||||
let public_key = signer_pkey.await?;
|
let public_key = signer_pkey.await?;
|
||||||
|
|
||||||
async_chat.update_in(cx, |this, window, cx| {
|
// Reset the find panel
|
||||||
let room = cx.new(|_| Room::new(public_key, receivers));
|
this.update_in(cx, |this, window, cx| {
|
||||||
this.emit_room(room.downgrade(), cx);
|
this.reset(window, cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
window.close_modal(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).kind(RoomKind::Ongoing));
|
||||||
|
this.emit_room(room.downgrade(), cx);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -366,7 +377,7 @@ impl Sidebar {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
RoomListItem::new(range.start + ix)
|
RoomEntry::new(range.start + ix)
|
||||||
.name(room.display_name(cx))
|
.name(room.display_name(cx))
|
||||||
.avatar(room.display_image(cx))
|
.avatar(room.display_image(cx))
|
||||||
.public_key(public_key)
|
.public_key(public_key)
|
||||||
@@ -378,62 +389,67 @@ impl Sidebar {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the contact list
|
||||||
|
fn render_results(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||||
|
let persons = PersonRegistry::global(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> {
|
fn render_contacts(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
|
||||||
|
|
||||||
|
// Get the contact list
|
||||||
let Some(contacts) = self.contact_list.read(cx) else {
|
let Some(contacts) = self.contact_list.read(cx) else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Map the contact list to a list of elements
|
||||||
contacts
|
contacts
|
||||||
.get(range.clone())
|
.get(range.clone())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(ix, item)| {
|
.map(|(ix, public_key)| {
|
||||||
let profile = persons.read(cx).get(item, cx);
|
let selected = self.is_selected(public_key, cx);
|
||||||
let pkey = item.to_owned();
|
let profile = persons.read(cx).get(public_key, cx);
|
||||||
let id = range.start + ix;
|
let pkey_clone = public_key.to_owned();
|
||||||
|
let handler = cx.listener(move |this, _ev, _window, cx| {
|
||||||
|
this.select(&pkey_clone, cx);
|
||||||
|
});
|
||||||
|
|
||||||
h_flex()
|
RoomEntry::new(range.start + ix)
|
||||||
.id(id)
|
.name(profile.name())
|
||||||
.h_8()
|
.avatar(profile.avatar())
|
||||||
.w_full()
|
.on_click(handler)
|
||||||
.px_1()
|
.selected(selected)
|
||||||
.gap_2()
|
|
||||||
.rounded(cx.theme().radius)
|
|
||||||
.when(!hide_avatar, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.size_6()
|
|
||||||
.rounded_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(Avatar::new(profile.avatar()).size(rems(1.5))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.justify_between()
|
|
||||||
.line_clamp(1)
|
|
||||||
.text_ellipsis()
|
|
||||||
.truncate()
|
|
||||||
.text_sm()
|
|
||||||
.child(profile.name())
|
|
||||||
.when(self.is_selected(pkey, cx), |this| {
|
|
||||||
this.child(
|
|
||||||
Icon::new(IconName::CheckCircle)
|
|
||||||
.small()
|
|
||||||
.text_color(cx.theme().icon_accent),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
|
||||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
|
||||||
this.select(pkey, cx);
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -630,7 +646,7 @@ impl Render for Sidebar {
|
|||||||
"rooms",
|
"rooms",
|
||||||
results.len(),
|
results.len(),
|
||||||
cx.processor(|this, range, _window, cx| {
|
cx.processor(|this, range, _window, cx| {
|
||||||
this.render_list_items(range, cx)
|
this.render_results(range, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|||||||
Reference in New Issue
Block a user