From 2ec98e14d088222bfb1f159f589296a7bb854076 Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 23 Feb 2026 15:48:35 +0700 Subject: [PATCH] wip: revamp title bar elements --- Cargo.lock | 2 + Cargo.toml | 2 +- assets/icons/panel-left-open.svg | 2 +- assets/icons/panel-left.svg | 2 +- assets/icons/panel-right-open.svg | 2 +- assets/icons/panel-right.svg | 2 +- assets/icons/refresh.svg | 3 + crates/chat/src/lib.rs | 264 ++++---- crates/chat_ui/src/lib.rs | 2 +- crates/coop/src/actions.rs | 94 --- crates/coop/src/panels/encryption_key.rs | 54 ++ crates/coop/src/panels/greeter.rs | 29 +- crates/coop/src/panels/mod.rs | 1 + crates/coop/src/sidebar/mod.rs | 2 +- crates/coop/src/workspace.rs | 315 +++++++-- crates/device/src/lib.rs | 2 +- crates/settings/src/lib.rs | 2 +- crates/state/src/device.rs | 2 +- crates/state/src/gossip.rs | 13 + crates/state/src/lib.rs | 9 +- crates/title_bar/src/lib.rs | 96 ++- crates/ui/src/button.rs | 32 +- crates/ui/src/dock_area/mod.rs | 18 +- crates/ui/src/dock_area/stack_panel.rs | 1 - crates/ui/src/dock_area/tab_panel.rs | 11 +- crates/ui/src/icon.rs | 2 + crates/ui/src/menu/menu_item.rs | 2 +- crates/ui/src/menu/popup_menu.rs | 14 +- crates/ui/src/popup_menu.rs | 776 ----------------------- crates/ui/src/resizable/panel.rs | 2 + crates/ui/src/styled.rs | 4 +- crates/ui/src/tooltip.rs | 10 +- 32 files changed, 595 insertions(+), 1177 deletions(-) create mode 100644 assets/icons/refresh.svg delete mode 100644 crates/coop/src/actions.rs create mode 100644 crates/coop/src/panels/encryption_key.rs delete mode 100644 crates/ui/src/popup_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 0bcc1fc..4a0d6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2735,6 +2735,7 @@ dependencies = [ "strum", "util", "uuid", + "zed-font-kit", ] [[package]] @@ -2812,6 +2813,7 @@ dependencies = [ "windows-core 0.61.2", "windows-numerics 0.2.0", "windows-registry 0.5.3", + "zed-scap", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 547811e..1780bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ publish = false # GPUI gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "screen-capture", "x11", "wayland", "runtime_shaders"] } gpui_linux = { git = "https://github.com/zed-industries/zed" } gpui_windows = { git = "https://github.com/zed-industries/zed" } gpui_macos = { git = "https://github.com/zed-industries/zed" } diff --git a/assets/icons/panel-left-open.svg b/assets/icons/panel-left-open.svg index a0f79d3..1281f6e 100644 --- a/assets/icons/panel-left-open.svg +++ b/assets/icons/panel-left-open.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/panel-left.svg b/assets/icons/panel-left.svg index 814388d..1c98d39 100644 --- a/assets/icons/panel-left.svg +++ b/assets/icons/panel-left.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/panel-right-open.svg b/assets/icons/panel-right-open.svg index 2d3e722..6387eae 100644 --- a/assets/icons/panel-right-open.svg +++ b/assets/icons/panel-right-open.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/panel-right.svg b/assets/icons/panel-right.svg index ea70e43..636e01d 100644 --- a/assets/icons/panel-right.svg +++ b/assets/icons/panel-right.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/refresh.svg b/assets/icons/refresh.svg new file mode 100644 index 0000000..b7ec11e --- /dev/null +++ b/assets/icons/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 21d2188..65c2cbb 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -65,6 +65,10 @@ impl InboxState { pub fn not_configured(&self) -> bool { matches!(self, InboxState::RelayNotAvailable) } + + pub fn subscribing(&self) -> bool { + matches!(self, InboxState::Subscribing) + } } /// Chat Registry @@ -133,14 +137,14 @@ impl ChatRegistry { // Run at the end of the current cycle cx.defer_in(window, |this, _window, cx| { + // Load chat rooms + this.get_rooms(cx); + // Handle nostr notifications this.handle_notifications(cx); // Track unwrap gift wrap progress this.tracking(cx); - - // Load chat rooms - this.get_rooms(cx); }); Self { @@ -190,7 +194,7 @@ impl ChatRegistry { } // Extract the rumor from the gift wrap event - match Self::extract_rumor(&client, &device_signer, event.as_ref()).await { + match extract_rumor(&client, &device_signer, event.as_ref()).await { Ok(rumor) => match rumor.created_at >= initialized_at { true => { let new_message = NewMessage::new(event.id, rumor); @@ -256,7 +260,7 @@ impl ChatRegistry { } /// Ensure messaging relays are set up for the current user. - fn ensure_messaging_relays(&mut self, cx: &mut Context) { + pub fn ensure_messaging_relays(&mut self, cx: &mut Context) { let task = self.verify_relays(cx); // Set state to checking @@ -556,7 +560,11 @@ impl ChatRegistry { let public_key = signer.get_public_key().await?; // Get contacts - let contacts = client.database().contacts_public_keys(public_key).await?; + let contacts = client + .database() + .contacts_public_keys(public_key) + .await + .unwrap_or_default(); // Construct authored filter let authored_filter = Filter::new() @@ -652,147 +660,149 @@ impl ChatRegistry { } } } +} - /// Unwraps a gift-wrapped event and processes its contents. - async fn extract_rumor( - client: &Client, - device_signer: &Option>, - gift_wrap: &Event, - ) -> Result { - // Try to get cached rumor first - if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await { - return Ok(event); - } - - // Try to unwrap with the available signer - let unwrapped = Self::try_unwrap(client, device_signer, gift_wrap).await?; - let mut rumor_unsigned = unwrapped.rumor; - - // Generate event id for the rumor if it doesn't have one - rumor_unsigned.ensure_id(); - - // Cache the rumor - Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?; - - Ok(rumor_unsigned) +/// Unwraps a gift-wrapped event and processes its contents. +async fn extract_rumor( + client: &Client, + device_signer: &Option>, + gift_wrap: &Event, +) -> Result { + // Try to get cached rumor first + if let Ok(event) = get_rumor(client, gift_wrap.id).await { + return Ok(event); } - /// Helper method to try unwrapping with different signers - async fn try_unwrap( - client: &Client, - device_signer: &Option>, - gift_wrap: &Event, - ) -> Result { - // Try with the device signer first - if let Some(signer) = device_signer { - if let Ok(unwrapped) = Self::try_unwrap_with(gift_wrap, signer).await { - return Ok(unwrapped); - }; + // Try to unwrap with the available signer + let unwrapped = try_unwrap(client, device_signer, gift_wrap).await?; + let mut rumor = unwrapped.rumor; + + // Generate event id for the rumor if it doesn't have one + rumor.ensure_id(); + + // Cache the rumor + if let Err(e) = set_rumor(client, gift_wrap.id, &rumor).await { + log::error!("Failed to cache rumor: {e:?}"); + } + + Ok(rumor) +} + +/// Helper method to try unwrapping with different signers +async fn try_unwrap( + client: &Client, + device_signer: &Option>, + gift_wrap: &Event, +) -> Result { + // Try with the device signer first + if let Some(signer) = device_signer { + if let Ok(unwrapped) = try_unwrap_with(gift_wrap, signer).await { + return Ok(unwrapped); }; + }; - // Try with the user's signer - let user_signer = client.signer().context("Signer not found")?; - let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?; + // Try with the user's signer + let user_signer = client.signer().context("Signer not found")?; + let unwrapped = UnwrappedGift::from_gift_wrap(user_signer, gift_wrap).await?; - Ok(unwrapped) - } + Ok(unwrapped) +} - /// Attempts to unwrap a gift wrap event with a given signer. - async fn try_unwrap_with( - gift_wrap: &Event, - signer: &Arc, - ) -> Result { - // Get the sealed event - let seal = signer - .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) - .await?; +/// Attempts to unwrap a gift wrap event with a given signer. +async fn try_unwrap_with( + gift_wrap: &Event, + signer: &Arc, +) -> Result { + // Get the sealed event + let seal = signer + .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) + .await?; - // Verify the sealed event - let seal: Event = Event::from_json(seal)?; - seal.verify_with_ctx(&SECP256K1)?; + // Verify the sealed event + let seal: Event = Event::from_json(seal)?; + seal.verify_with_ctx(&SECP256K1)?; - // Get the rumor event - let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?; - let rumor = UnsignedEvent::from_json(rumor)?; + // Get the rumor event + let rumor = signer.nip44_decrypt(&seal.pubkey, &seal.content).await?; + let rumor = UnsignedEvent::from_json(rumor)?; - Ok(UnwrappedGift { - sender: seal.pubkey, - rumor, - }) - } + Ok(UnwrappedGift { + sender: seal.pubkey, + rumor, + }) +} - /// Stores an unwrapped event in local database with reference to original - async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { - let rumor_id = rumor.id.context("Rumor is missing an event id")?; - let author = rumor.pubkey; - let conversation = Self::conversation_id(rumor); +/// Stores an unwrapped event in local database with reference to original +async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { + let rumor_id = rumor.id.context("Rumor is missing an event id")?; + let author = rumor.pubkey; + let conversation = conversation_id(rumor); - let mut tags = rumor.tags.clone().to_vec(); + let mut tags = rumor.tags.clone().to_vec(); - // Add a unique identifier - tags.push(Tag::identifier(id)); + // Add a unique identifier + tags.push(Tag::identifier(id)); - // Add a reference to the rumor's author + // Add a reference to the rumor's author + tags.push(Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)), + [author], + )); + + // Add a conversation id + tags.push(Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)), + [conversation.to_string()], + )); + + // Add a reference to the rumor's id + tags.push(Tag::event(rumor_id)); + + // Add references to the rumor's participants + for receiver in rumor.tags.public_keys().copied() { tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)), - [author], + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), + [receiver], )); - - // Add a conversation id - tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)), - [conversation.to_string()], - )); - - // Add a reference to the rumor's id - tags.push(Tag::event(rumor_id)); - - // Add references to the rumor's participants - for receiver in rumor.tags.public_keys().copied() { - tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), - [receiver], - )); - } - - // Convert rumor to json - let content = rumor.as_json(); - - // Construct the event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tags(tags) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - client.database().save_event(&event).await?; - - Ok(()) } - /// Retrieves a previously unwrapped event from local database - async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(gift_wrap) - .limit(1); + // Convert rumor to json + let content = rumor.as_json(); - if let Some(event) = client.database().query(filter).await?.first_owned() { - UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e)) - } else { - Err(anyhow!("Event is not cached yet.")) - } - } + // Construct the event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tags(tags) + .sign(&Keys::generate()) + .await?; - /// Get the conversation ID for a given rumor (message). - fn conversation_id(rumor: &UnsignedEvent) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = rumor.tags.public_keys().copied().collect(); - pubkeys.push(rumor.pubkey); - pubkeys.sort(); - pubkeys.dedup(); - pubkeys.hash(&mut hasher); + // Save the event to the database + client.database().save_event(&event).await?; - hasher.finish() + Ok(()) +} + +/// Retrieves a previously unwrapped event from local database +async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(gift_wrap) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e)) + } else { + Err(anyhow!("Event is not cached yet.")) } } + +/// Get the conversation ID for a given rumor (message). +fn conversation_id(rumor: &UnsignedEvent) -> u64 { + let mut hasher = DefaultHasher::new(); + let mut pubkeys: Vec = rumor.tags.public_keys().copied().collect(); + pubkeys.push(rumor.pubkey); + pubkeys.sort(); + pubkeys.dedup(); + pubkeys.hash(&mut hasher); + + hasher.finish() +} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index f307595..b346f1e 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -657,7 +657,7 @@ impl ChatPanel { .id(ix) .relative() .w_full() - .py_1() + .py_2() .px_3() .bg(cx.theme().warning_background) .child( diff --git a/crates/coop/src/actions.rs b/crates/coop/src/actions.rs deleted file mode 100644 index 8a5798b..0000000 --- a/crates/coop/src/actions.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::sync::Mutex; - -use gpui::{actions, App}; -use key_store::{KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use state::NostrRegistry; - -// Sidebar actions -actions!(sidebar, [Reload, RelayStatus]); - -// User actions -actions!( - coop, - [ - KeyringPopup, - DarkMode, - ViewProfile, - ViewRelays, - Themes, - Settings, - Logout, - Quit - ] -); - -#[derive(Debug, Clone)] -pub struct CoopAuthUrlHandler; - -impl AuthUrlHandler for CoopAuthUrlHandler { - #[allow(mismatched_lifetime_syntaxes)] - fn on_auth_url(&self, auth_url: Url) -> BoxedFuture> { - Box::pin(async move { - log::info!("Received Auth URL: {auth_url}"); - webbrowser::open(auth_url.as_str())?; - Ok(()) - }) - } -} - -pub fn load_embedded_fonts(cx: &App) { - let asset_source = cx.asset_source(); - let font_paths = asset_source.list("fonts").unwrap(); - let embedded_fonts = Mutex::new(Vec::new()); - let executor = cx.background_executor(); - - cx.foreground_executor().block_on(executor.scoped(|scope| { - for font_path in &font_paths { - if !font_path.ends_with(".ttf") { - continue; - } - - scope.spawn(async { - let font_bytes = asset_source.load(font_path).unwrap().unwrap(); - embedded_fonts.lock().unwrap().push(font_bytes); - }); - } - })); - - cx.text_system() - .add_fonts(embedded_fonts.into_inner().unwrap()) - .unwrap(); -} - -pub fn reset(cx: &mut App) { - let backend = KeyStore::global(cx).read(cx).backend(); - let client = NostrRegistry::global(cx).read(cx).client(); - - cx.spawn(async move |cx| { - // Remove the signer - client.unset_signer().await; - - // Delete user's credentials - backend - .delete_credentials(&KeyItem::User.to_string(), cx) - .await - .ok(); - - // Remove bunker's credentials if available - backend - .delete_credentials(&KeyItem::Bunker.to_string(), cx) - .await - .ok(); - - cx.update(|cx| { - cx.restart(); - }); - }) - .detach(); -} - -pub fn quit(_: &Quit, cx: &mut App) { - log::info!("Gracefully quitting the application . . ."); - cx.quit(); -} diff --git a/crates/coop/src/panels/encryption_key.rs b/crates/coop/src/panels/encryption_key.rs new file mode 100644 index 0000000..0f243ac --- /dev/null +++ b/crates/coop/src/panels/encryption_key.rs @@ -0,0 +1,54 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, Render, SharedString, Styled, Window, +}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::v_flex; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| EncryptionPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct EncryptionPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl EncryptionPanel { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + name: "Encryption".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for EncryptionPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for EncryptionPanel {} + +impl Focusable for EncryptionPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for EncryptionPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .p_2() + .gap_10() + } +} diff --git a/crates/coop/src/panels/greeter.rs b/crates/coop/src/panels/greeter.rs index 99cc7d6..bdee671 100644 --- a/crates/coop/src/panels/greeter.rs +++ b/crates/coop/src/panels/greeter.rs @@ -1,8 +1,8 @@ use chat::{ChatRegistry, InboxState}; use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window, + div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, }; use state::{NostrRegistry, RelayState}; use theme::ActiveTheme; @@ -122,14 +122,13 @@ impl Render for GreeterPanel { .child( div() .font_semibold() - .line_height(relative(1.25)) + .text_color(cx.theme().text) .child(SharedString::from(TITLE)), ) .child( div() - .text_sm() + .text_xs() .text_color(cx.theme().text_muted) - .line_height(relative(1.25)) .child(SharedString::from(DESCRIPTION)), ), ), @@ -141,9 +140,9 @@ impl Render for GreeterPanel { .w_full() .child( h_flex() - .gap_1() + .gap_2() .w_full() - .text_sm() + .text_xs() .font_semibold() .text_color(cx.theme().text_muted) .child(SharedString::from("Required Actions")) @@ -199,9 +198,9 @@ impl Render for GreeterPanel { .w_full() .child( h_flex() - .gap_1() + .gap_2() .w_full() - .text_sm() + .text_xs() .font_semibold() .text_color(cx.theme().text_muted) .child(SharedString::from("Use your own identity")) @@ -252,9 +251,9 @@ impl Render for GreeterPanel { .w_full() .child( h_flex() - .gap_1() + .gap_2() .w_full() - .text_sm() + .text_xs() .font_semibold() .text_color(cx.theme().text_muted) .child(SharedString::from("Get Started")) @@ -264,14 +263,6 @@ impl Render for GreeterPanel { v_flex() .gap_2() .w_full() - .child( - Button::new("backup") - .icon(Icon::new(IconName::Shield)) - .label("Backup account") - .ghost() - .small() - .justify_start(), - ) .child( Button::new("profile") .icon(Icon::new(IconName::Profile)) diff --git a/crates/coop/src/panels/mod.rs b/crates/coop/src/panels/mod.rs index 7e6e20a..259167c 100644 --- a/crates/coop/src/panels/mod.rs +++ b/crates/coop/src/panels/mod.rs @@ -1,4 +1,5 @@ pub mod connect; +pub mod encryption_key; pub mod greeter; pub mod import; pub mod messaging_relays; diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index 9c54cc1..a07ad17 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -659,7 +659,7 @@ impl Render for Sidebar { .text_xs() .font_semibold() .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::ChevronDown)) + .child(Icon::new(IconName::ChevronDown).small()) .child(SharedString::from("Suggestions")), ) .child( diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 532ee81..6554137 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -3,29 +3,40 @@ use std::sync::Arc; use chat::{ChatEvent, ChatRegistry, InboxState}; use gpui::prelude::FluentBuilder; use gpui::{ - div, rems, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, + div, px, rems, Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, }; use person::PersonRegistry; +use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use state::{NostrRegistry, RelayState}; -use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; +use theme::{ActiveTheme, Theme, SIDEBAR_WIDTH}; use title_bar::TitleBar; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock_area::dock::DockPlacement; -use ui::dock_area::panel::{PanelStyle, PanelView}; +use ui::dock_area::panel::PanelView; use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::menu::DropdownMenu; -use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; +use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; -use crate::panels::greeter; +use crate::panels::{encryption_key, greeter, messaging_relays, relay_list}; use crate::sidebar; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = workspace, no_json)] +enum Command { + ReloadRelayList, + OpenRelayPanel, + ReloadInbox, + OpenInboxPanel, + OpenEncryptionPanel, +} + pub struct Workspace { /// App's Title Bar titlebar: Entity, @@ -41,7 +52,7 @@ impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let titlebar = cx.new(|_| TitleBar::new()); - let dock = cx.new(|cx| DockArea::new(window, cx).style(PanelStyle::TabBar)); + let dock = cx.new(|cx| DockArea::new(window, cx)); let mut subscriptions = smallvec![]; @@ -168,14 +179,59 @@ impl Workspace { }); } + fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { + match command { + Command::OpenEncryptionPanel => { + self.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(encryption_key::init(window, cx)), + DockPlacement::Right, + window, + cx, + ); + }); + } + Command::OpenInboxPanel => { + self.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(messaging_relays::init(window, cx)), + DockPlacement::Right, + window, + cx, + ); + }); + } + Command::OpenRelayPanel => { + self.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(relay_list::init(window, cx)), + DockPlacement::Right, + window, + cx, + ); + }); + } + Command::ReloadInbox => { + let nostr = NostrRegistry::global(cx); + nostr.update(cx, |this, cx| { + this.ensure_relay_list(cx); + }); + } + Command::ReloadRelayList => { + let chat = ChatRegistry::global(cx); + chat.update(cx, |this, cx| { + this.ensure_messaging_relays(cx); + }); + } + } + } + fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { - let chat = ChatRegistry::global(cx); let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); let current_user = signer.public_key(); h_flex() - .h(TITLEBAR_HEIGHT) .flex_shrink_0() .justify_between() .gap_2() @@ -213,51 +269,207 @@ impl Workspace { .child(SharedString::from("Connecting...")), ) }) - .map(|this| match nostr.read(cx).relay_list_state() { - RelayState::Checking => this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Fetching user's relay list...")), - ), - RelayState::NotConfigured => this.child( - h_flex() - .h_6() - .w_full() - .px_1() - .text_xs() - .text_color(cx.theme().warning_foreground) - .bg(cx.theme().warning_background) - .rounded_sm() - .child(SharedString::from("User hasn't configured a relay list")), - ), - _ => this, - }) - .map(|this| match chat.read(cx).state(cx) { - InboxState::Checking => { - this.child(div().text_xs().text_color(cx.theme().text_muted).child( - SharedString::from("Fetching user's messaging relay list..."), - )) - } - InboxState::RelayNotAvailable => this.child( - h_flex() - .h_6() - .w_full() - .px_2() - .text_xs() - .text_color(cx.theme().warning_foreground) - .bg(cx.theme().warning_background) - .rounded_full() - .child(SharedString::from( - "User hasn't configured a messaging relay list", - )), - ), - _ => this, - }) } - fn titlebar_right(&mut self, _window: &mut Window, _cx: &Context) -> impl IntoElement { - h_flex().h(TITLEBAR_HEIGHT).flex_shrink_0() + fn titlebar_right(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let relay_list = nostr.read(cx).relay_list_state(); + + let chat = ChatRegistry::global(cx); + let inbox_state = chat.read(cx).state(cx); + + let Some(pkey) = signer.public_key() else { + return div(); + }; + + h_flex() + .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) + .gap_3() + .child( + Button::new("key") + .icon(IconName::UserKey) + .tooltip("Decoupled encryption key") + .small() + .ghost() + .on_click(|_ev, window, cx| { + window.dispatch_action(Box::new(Command::OpenEncryptionPanel), cx); + }), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .map(|this| match inbox_state { + InboxState::Checking => this.child(div().child( + SharedString::from("Fetching user's messaging relay list..."), + )), + InboxState::RelayNotAvailable => { + this.child(div().text_color(cx.theme().warning_active).child( + SharedString::from( + "User hasn't configured a messaging relay list", + ), + )) + } + _ => this, + }), + ) + .child( + Button::new("inbox") + .icon(IconName::Inbox) + .tooltip("Inbox") + .small() + .ghost() + .when(inbox_state.subscribing(), |this| this.indicator()) + .dropdown_menu(move |this, _window, _cx| { + this.min_w(px(260.)) + .label("Messaging Relays") + .menu_element_with_disabled( + Box::new(Command::OpenRelayPanel), + true, + move |_window, cx| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(&pkey, cx); + let urls = profile.messaging_relays(); + + v_flex() + .gap_1() + .w_full() + .items_start() + .justify_start() + .children({ + let mut items = vec![]; + + for url in urls.iter() { + items.push( + h_flex() + .h_6() + .w_full() + .gap_2() + .px_2() + .text_xs() + .bg(cx + .theme() + .elevated_surface_background) + .rounded(cx.theme().radius) + .child( + div() + .size_1() + .rounded_full() + .bg(gpui::green()), + ) + .child(SharedString::from( + url.to_string(), + )), + ); + } + + items + }) + }, + ) + .separator() + .menu_with_icon( + "Reload", + IconName::Refresh, + Box::new(Command::ReloadInbox), + ) + .menu_with_icon( + "Update relays", + IconName::Settings, + Box::new(Command::OpenInboxPanel), + ) + }), + ), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .map(|this| match relay_list { + RelayState::Checking => this + .child(div().child(SharedString::from( + "Fetching user's relay list...", + ))), + RelayState::NotConfigured => { + this.child(div().text_color(cx.theme().warning_active).child( + SharedString::from("User hasn't configured a relay list"), + )) + } + _ => this, + }), + ) + .child( + Button::new("relay-list") + .icon(IconName::Relay) + .tooltip("User's relay list") + .small() + .ghost() + .when(relay_list.configured(), |this| this.indicator()) + .dropdown_menu(move |this, _window, _cx| { + this.min_w(px(260.)) + .label("Relays") + .menu_element_with_disabled( + Box::new(Command::OpenRelayPanel), + true, + move |_window, cx| { + let nostr = NostrRegistry::global(cx); + let urls = nostr.read(cx).read_only_relays(&pkey, cx); + + v_flex() + .gap_1() + .w_full() + .items_start() + .justify_start() + .children({ + let mut items = vec![]; + + for url in urls.into_iter() { + items.push( + h_flex() + .h_6() + .w_full() + .gap_2() + .px_2() + .text_xs() + .bg(cx + .theme() + .elevated_surface_background) + .rounded(cx.theme().radius) + .child( + div() + .size_1() + .rounded_full() + .bg(gpui::green()), + ) + .child(url), + ); + } + + items + }) + }, + ) + .separator() + .menu_with_icon( + "Reload", + IconName::Refresh, + Box::new(Command::ReloadRelayList), + ) + .menu_with_icon( + "Update relay list", + IconName::Settings, + Box::new(Command::OpenRelayPanel), + ) + }), + ), + ) } } @@ -277,6 +489,7 @@ impl Render for Workspace { div() .id(SharedString::from("workspace")) + .on_action(cx.listener(Self::on_command)) .relative() .size_full() .child( diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 28aceb4..f711950 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -180,7 +180,7 @@ impl DeviceRegistry { /// Reset the device state fn reset(&mut self, cx: &mut Context) { - self.state = DeviceState::Initial; + self.state = DeviceState::Idle; self.requests.update(cx, |this, cx| { this.clear(); cx.notify(); diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 8039a6b..c4ce94a 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -52,8 +52,8 @@ pub enum AuthMode { /// Signer kind #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum SignerKind { - Auto, #[default] + Auto, User, Encryption, } diff --git a/crates/state/src/device.rs b/crates/state/src/device.rs index 156be81..93dfd38 100644 --- a/crates/state/src/device.rs +++ b/crates/state/src/device.rs @@ -4,7 +4,7 @@ use nostr_sdk::prelude::*; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub enum DeviceState { #[default] - Initial, + Idle, Requesting, Set, } diff --git a/crates/state/src/gossip.rs b/crates/state/src/gossip.rs index 5cbe245..805a1ac 100644 --- a/crates/state/src/gossip.rs +++ b/crates/state/src/gossip.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use gpui::SharedString; use nostr_sdk::prelude::*; /// Gossip @@ -9,6 +10,18 @@ pub struct Gossip { } impl Gossip { + pub fn read_only_relays(&self, public_key: &PublicKey) -> Vec { + self.relays + .get(public_key) + .map(|relays| { + relays + .iter() + .map(|(url, _)| url.to_string().into()) + .collect() + }) + .unwrap_or_default() + } + /// Get read relays for a given public key pub fn read_relays(&self, public_key: &PublicKey) -> Vec { self.relays diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 4ab7667..65e5d5c 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; +use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; use nostr_connect::prelude::*; use nostr_lmdb::prelude::*; use nostr_sdk::prelude::*; @@ -247,6 +247,11 @@ impl NostrRegistry { self.relay_list_state.clone() } + /// Get all relays for a given public key without ensuring connections + pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { + self.gossip.read(cx).read_only_relays(public_key) + } + /// Get a list of write relays for a given public key pub fn write_relays(&self, public_key: &PublicKey, cx: &App) -> Task> { let client = self.client(); @@ -483,7 +488,7 @@ impl NostrRegistry { cx.notify(); } - fn ensure_relay_list(&mut self, cx: &mut Context) { + pub fn ensure_relay_list(&mut self, cx: &mut Context) { let task = self.verify_relay_list(cx); self.tasks.push(cx.spawn(async move |this, cx| { diff --git a/crates/title_bar/src/lib.rs b/crates/title_bar/src/lib.rs index 4bbaa0e..de7f670 100644 --- a/crates/title_bar/src/lib.rs +++ b/crates/title_bar/src/lib.rs @@ -4,7 +4,7 @@ use gpui::MouseButton; #[cfg(not(target_os = "windows"))] use gpui::Pixels; use gpui::{ - div, px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, + px, AnyElement, Context, Decorations, Hsla, InteractiveElement as _, IntoElement, ParentElement, Render, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, }; use smallvec::{smallvec, SmallVec}; @@ -127,62 +127,48 @@ impl Render for TitleBar { } }) }) + .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) .children(children), ) - .child( - h_flex() - .absolute() - .top_0() - .right_0() - .pr_2() - .h(height) - .child( - div().when(!window.is_fullscreen(), |this| match cx.theme().platform { - PlatformKind::Linux => { - #[cfg(target_os = "linux")] - if matches!(decorations, Decorations::Client { .. }) { - this.child(LinuxWindowControls::new(None)) - .when(supported_controls.window_menu, |this| { - this.on_mouse_down( - MouseButton::Right, - move |ev, window, _| { - window.show_window_menu(ev.position) - }, - ) - }) - .on_mouse_move(cx.listener(move |this, _ev, window, _| { - if this.should_move { - this.should_move = false; - window.start_window_move(); - } - })) - .on_mouse_down_out(cx.listener( - move |this, _ev, _window, _cx| { - this.should_move = false; - }, - )) - .on_mouse_up( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = false; - }), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _ev, _window, _cx| { - this.should_move = true; - }), - ) - } else { - this + .when(!window.is_fullscreen(), |this| match cx.theme().platform { + PlatformKind::Linux => { + #[cfg(target_os = "linux")] + if matches!(decorations, Decorations::Client { .. }) { + this.child(LinuxWindowControls::new(None)) + .when(supported_controls.window_menu, |this| { + this.on_mouse_down(MouseButton::Right, move |ev, window, _| { + window.show_window_menu(ev.position) + }) + }) + .on_mouse_move(cx.listener(move |this, _ev, window, _| { + if this.should_move { + this.should_move = false; + window.start_window_move(); } - #[cfg(not(target_os = "linux"))] - this - } - PlatformKind::Windows => this.child(WindowsWindowControls::new(height)), - PlatformKind::Mac => this, - }), - ), - ) + })) + .on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| { + this.should_move = false; + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = false; + }), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _ev, _window, _cx| { + this.should_move = true; + }), + ) + } else { + this + } + #[cfg(not(target_os = "linux"))] + this + } + PlatformKind::Windows => this.child(WindowsWindowControls::new(height)), + PlatformKind::Mac => this, + }) } } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index e15b2ea..8f35e1e 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -131,8 +131,8 @@ pub struct Button { rounded: bool, compact: bool, - underline: bool, caret: bool, + indicator: bool, on_click: Option>, on_hover: Option>, @@ -162,7 +162,7 @@ impl Button { variant: ButtonVariant::default(), disabled: false, selected: false, - underline: false, + indicator: false, compact: false, caret: false, rounded: false, @@ -219,9 +219,9 @@ impl Button { self } - /// Set true to show the underline indicator. - pub fn underline(mut self) -> Self { - self.underline = true; + /// Set true to show the indicator. + pub fn indicator(mut self) -> Self { + self.indicator = true; self } @@ -455,6 +455,17 @@ impl RenderOnce for Button { }) }) .text_color(normal_style.fg) + .when(self.indicator && !self.disabled, |this| { + this.child( + div() + .absolute() + .bottom_px() + .right_px() + .size_1() + .rounded_full() + .bg(gpui::green()), + ) + }) .when(!self.disabled && !self.selected, |this| { this.bg(normal_style.bg) .hover(|this| { @@ -470,17 +481,6 @@ impl RenderOnce for Button { let selected_style = style.selected(cx); this.bg(selected_style.bg).text_color(selected_style.fg) }) - .when(self.selected && self.underline, |this| { - this.child( - div() - .absolute() - .bottom_0() - .left_0() - .h_px() - .w_full() - .bg(cx.theme().element_background), - ) - }) .when(self.disabled, |this| { let disabled_style = style.disabled(cx); this.cursor_not_allowed() diff --git a/crates/ui/src/dock_area/mod.rs b/crates/ui/src/dock_area/mod.rs index 670882d..2312740 100644 --- a/crates/ui/src/dock_area/mod.rs +++ b/crates/ui/src/dock_area/mod.rs @@ -590,17 +590,13 @@ impl DockArea { } } DockPlacement::Right => { - if let Some(dock) = self.right_dock.as_ref() { - dock.update(cx, |dock, cx| dock.add_panel(panel, window, cx)) - } else { - self.set_right_dock( - DockItem::tabs(vec![panel], None, &weak_self, window, cx), - Some(px(320.)), - true, - window, - cx, - ); - } + self.set_right_dock( + DockItem::tabs(vec![panel], None, &weak_self, window, cx), + Some(px(320.)), + true, + window, + cx, + ); } DockPlacement::Center => { self.items diff --git a/crates/ui/src/dock_area/stack_panel.rs b/crates/ui/src/dock_area/stack_panel.rs index 79ba4ef..c2cb7f5 100644 --- a/crates/ui/src/dock_area/stack_panel.rs +++ b/crates/ui/src/dock_area/stack_panel.rs @@ -371,7 +371,6 @@ impl Focusable for StackPanel { } impl EventEmitter for StackPanel {} - impl EventEmitter for StackPanel {} impl Render for StackPanel { diff --git a/crates/ui/src/dock_area/tab_panel.rs b/crates/ui/src/dock_area/tab_panel.rs index 7ed3817..6e412fa 100644 --- a/crates/ui/src/dock_area/tab_panel.rs +++ b/crates/ui/src/dock_area/tab_panel.rs @@ -479,12 +479,12 @@ impl TabPanel { _window: &mut Window, cx: &mut Context, ) -> Option