diff --git a/Cargo.lock b/Cargo.lock index ff03bcb9..44fc120f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,11 +699,11 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitcoin-consensus-encoding" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.7.0", "hex-conservative 1.3.0", "serde", ] @@ -714,6 +714,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -740,7 +746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" dependencies = [ "bitcoin-consensus-encoding", - "bitcoin-internals", + "bitcoin-internals 0.6.0", "hex-conservative 1.3.0", "serde", ] @@ -987,9 +993,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -2229,9 +2235,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -4052,9 +4058,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -4753,9 +4759,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.18.0" +version = "4.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" dependencies = [ "futures-lite", "log", @@ -6996,9 +7002,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skrifa" @@ -7037,9 +7043,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smol" @@ -7441,12 +7447,12 @@ dependencies = [ [[package]] name = "tauri-winrt-notification" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" dependencies = [ "thiserror 2.0.21", - "windows 0.61.3", + "windows 0.62.2", "windows-version", ] @@ -8363,9 +8369,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -8376,19 +8382,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8396,9 +8403,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -8409,9 +8416,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -8542,9 +8549,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -9832,18 +9839,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.58" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs index 21a17538..02ff3bb3 100644 --- a/crates/ui/src/nav.rs +++ b/crates/ui/src/nav.rs @@ -101,8 +101,8 @@ impl RenderOnce for Nav { h_flex() .gap_1() .flex_1() - .child(div().truncate().min_w_0().child(self.label)) - .child(div().flex_1()) + .min_w_0() + .child(div().flex_1().min_w_0().truncate().child(self.label)) .when_some(self.suffix, |this, suffix| { this.child(div().flex_shrink_0().child(suffix)) }), diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 4e19ffb8..0bc0edbd 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,16 +1,18 @@ +use std::collections::HashSet; use std::ops::Range; use std::rc::Rc; +use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful, - Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px, - retain_all, uniform_list, + Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, + px, retain_all, uniform_list, }; use nostr_sdk::prelude::*; use person::PersonRegistry; @@ -45,6 +47,7 @@ pub(crate) use utils::{nav_avatar, nav_icon, pick_banner}; pub enum SidebarRow { Room { room: Entity }, Community { community: Entity }, + Contact { public_key: PublicKey }, } /// A collapsible group of rows in the sidebar's community view. @@ -81,10 +84,19 @@ pub struct Sidebar { active_tab: SidebarTab, /// The community the sidebar is browsing, if any community: Option>, + /// Whether the inbox list is showing contacts to start a chat with + contacts_open: bool, + /// The signed-in user's contacts, loaded when the contact picker opens + contacts: Option>, + /// Contacts selected in the contact picker + selected_contacts: HashSet, + /// Scroll state of the contact picker's list + contacts_scroll: UniformListScrollHandle, channels_open: bool, admins_open: bool, members_open: bool, new_requests: bool, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -136,10 +148,15 @@ impl Sidebar { banner: pick_banner(), active_tab: SidebarTab::Inbox, community: None, + contacts_open: false, + contacts: None, + selected_contacts: HashSet::new(), + contacts_scroll: UniformListScrollHandle::new(), channels_open: true, admins_open: true, members_open: true, new_requests: false, + tasks: smallvec![], _subscriptions: subscriptions, } } @@ -149,9 +166,134 @@ impl Sidebar { return; } self.active_tab = tab; + self.close_contacts(); cx.notify(); } + /// Leave the contact picker and clear its selection. + fn close_contacts(&mut self) { + self.contacts_open = false; + self.selected_contacts.clear(); + } + + /// Show or hide the contact picker in the inbox list. + fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { + self.contacts_open = !self.contacts_open; + + if !self.contacts_open { + self.selected_contacts.clear(); + } else if self.contacts.is_none() { + self.load_contacts(window, cx); + } + + cx.notify(); + } + + /// Load the signed-in user's contact list into the contact picker. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = 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.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + /// Toggle a contact's selection in the contact picker. + fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { + if !self.selected_contacts.remove(public_key) { + self.selected_contacts.insert(public_key.to_owned()); + } + cx.notify(); + } + + /// Start a chat with the contacts selected in the picker. + fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { + if self.selected_contacts.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + let dock = self.dock.clone(); + + let receivers: Vec = self.selected_contacts.iter().copied().collect(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = async_chat.update_in(cx, |chat, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + chat.track_room(&room, cx); + room + })?; + + cx.update(|window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + })?; + + // Leave the picker now that the chat is open + this.update(cx, |this, cx| { + this.close_contacts(); + cx.notify(); + })?; + + Ok(()) + })); + } + /// Leave the community view, returning the sidebar to its tab list. fn reset_community(&mut self, cx: &mut Context) { if self.community.take().is_none() { @@ -293,13 +435,30 @@ impl Sidebar { fn render_tabs(&mut self, cx: &mut Context) -> AnyElement { let sidebar = cx.entity().downgrade(); let active_tab = self.active_tab; + let contacts_open = self.contacts_open && active_tab == SidebarTab::Inbox; let rows = Rc::new(self.rows_for(active_tab, cx)); - let scroll_handle = &self.scroll_handles[active_tab.index()]; + let create_button = contacts_open && !self.selected_contacts.is_empty(); + + let (list_id, scroll_handle) = if contacts_open { + ("sidebar-contacts", &self.contacts_scroll) + } else { + ( + active_tab.list_id(), + &self.scroll_handles[active_tab.index()], + ) + }; + + let list_title = if contacts_open { + "Contacts" + } else { + active_tab.list_title() + }; v_flex() .size_full() .flex_1() .min_h_0() + .relative() .gap_2() .child( div().px_2().child( @@ -358,9 +517,12 @@ impl Sidebar { ) .child( NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowContactList), cx) - }), + .when(contacts_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_contacts(window, cx); + })), ), ), SidebarTab::Communities => this.child( @@ -391,7 +553,7 @@ impl Sidebar { .text_xs() .font_semibold() .text_color(cx.theme().text_placeholder) - .child(active_tab.list_title()), + .child(list_title), ) .child( div() @@ -399,7 +561,7 @@ impl Sidebar { .flex_1() .child( uniform_list( - active_tab.list_id(), + list_id, rows.len(), cx.processor(move |this, range, _window, cx| { this.render_rows(range, rows.as_slice(), cx) @@ -411,6 +573,43 @@ impl Sidebar { ) .child(Scrollbar::vertical(scroll_handle)), ) + .when(create_button, |this| { + this.child( + v_flex() + .absolute() + .bottom_0() + .left_0() + .justify_center() + .h_20() + .w_full() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + Button::new("create-chat") + .label(if self.selected_contacts.len() > 1 { + "Create Group Chat" + } else { + "Create Chat" + }) + .primary() + .on_click(cx.listener(|this, _ev, window, cx| { + this.create_chat(window, cx); + })), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child("Selected") + .child(div().font_semibold().child(SharedString::from( + self.selected_contacts.len().to_string(), + ))), + ), + ) + }) .into_any_element() } @@ -537,6 +736,13 @@ impl Sidebar { fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec { match tab { + SidebarTab::Inbox if self.contacts_open => self + .contacts + .clone() + .unwrap_or_default() + .into_iter() + .map(|public_key| SidebarRow::Contact { public_key }) + .collect(), SidebarTab::Inbox => { let chat = ChatRegistry::global(cx); chat.read(cx) @@ -605,6 +811,31 @@ impl Sidebar { }) .into_any_element() } + SidebarRow::Contact { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let selected = self.selected_contacts.contains(public_key); + let sidebar = cx.entity().downgrade(); + let public_key = public_key.to_owned(); + + Nav::new(SharedString::from(format!("contact-{index}"))) + .label(person.name()) + .text_sm() + .font_medium() + .when_some( + nav_avatar(Some(person.avatar_seed()), person.avatar(), cx), + |this, avatar| this.prefix(avatar), + ) + .selected(selected) + .on_click(move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.toggle_contact(&public_key, cx); + }) { + log::error!("Failed to select contact: {error}"); + } + }) + .into_any_element() + } SidebarRow::Community { community } => { let name = community.read(cx).name(); let seed = community.read(cx).id().to_hex();