diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a6357..0cb3aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added - Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI +- Add a dropdown menu on the signed-in user button in the sidebar +- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential ### Changed diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 1b53323..f685d02 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -5,7 +5,9 @@ use std::time::{Duration, Instant}; use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; -use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, WeakEntity}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, EventEmitter, Global, Task, TaskExt, WeakEntity, +}; use nostr::event::IntoEventBuilder; use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; @@ -944,6 +946,20 @@ impl Backend { self.passphrase_required } + pub fn sign_out(&mut self, cx: &mut Context) { + self.current_user = None; + self.passphrase_required = false; + + self.inbox.update(cx, |inbox, cx| { + inbox.reset(cx); + }); + + cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx); + cx.emit(BackendEvent::SignerChanged); + cx.emit(BackendEvent::SignerRequired); + cx.notify(); + } + fn sync_inbox(&mut self, cx: &mut Context) { let repo_store = RepoListStore::global(cx); let client = self.client.clone(); diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index b1ae8a1..c235cac 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -3,6 +3,7 @@ mod dialog_state; pub(crate) mod discussion; mod inbox; mod issues; +mod placeholder_panel; mod pull_requests; mod repo; mod repo_list; @@ -15,6 +16,7 @@ use gpui::prelude::*; use gpui::{AnyElement, App, div}; use gpui_component::{Sizable as _, h_flex}; pub use inbox::InboxView; +pub(crate) use placeholder_panel::PlaceholderPanel; pub use repo::RepoDetailView; pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; diff --git a/crates/workspace/src/views/placeholder_panel.rs b/crates/workspace/src/views/placeholder_panel.rs new file mode 100644 index 0000000..6c4afd9 --- /dev/null +++ b/crates/workspace/src/views/placeholder_panel.rs @@ -0,0 +1,48 @@ +use dock::{BasePanel, Panel, PanelEvent}; +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, Render, SharedString, Window, +}; +use signed_ui::placeholder; + +/// A center panel reserving the place of a view that is not built yet. +pub struct PlaceholderPanel { + name: &'static str, + title: SharedString, + focus_handle: FocusHandle, +} + +impl PlaceholderPanel { + pub(crate) fn new(name: &'static str, title: &'static str, cx: &mut Context) -> Self { + Self { + name, + title: SharedString::from(title), + focus_handle: cx.focus_handle(), + } + } +} + +impl BasePanel for PlaceholderPanel { + fn panel_name(&self) -> &'static str { + self.name + } +} + +impl Panel for PlaceholderPanel { + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.title.clone() + } +} + +impl EventEmitter for PlaceholderPanel {} + +impl Focusable for PlaceholderPanel { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PlaceholderPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + placeholder("Nothing here yet.", cx) + } +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index a4318e6..2cd910b 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -14,9 +14,11 @@ use gpui::{ SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, }; use gpui_base::Button as BaseButton; -use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::button::{Button, ButtonVariant, ButtonVariants}; +use gpui_component::dialog::DialogButtonProps; use gpui_component::input::InputState; -use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use gpui_component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex}; use nostr::prelude::RelayUrl; use signed_core::{Announcement, RepoAddr}; use signed_state::{ @@ -25,7 +27,7 @@ use signed_state::{ }; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; -use super::{InboxView, RepoDetailView, RepoListView, open_repo_panel}; +use super::{InboxView, PlaceholderPanel, RepoDetailView, RepoListView, open_repo_panel}; mod create_repo_dialog; pub(crate) mod grasp_servers; @@ -54,6 +56,8 @@ pub struct SidebarPanel { dock_area: WeakEntity, inbox: Option>, explore: Option>, + profile: Option>, + relays: Option>, banner: SharedString, /// User's announced repositories. announcements: Arc>, @@ -113,6 +117,8 @@ impl SidebarPanel { dock_area, inbox: None, explore: None, + profile: None, + relays: None, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), @@ -221,6 +227,51 @@ impl SidebarPanel { .ok(); } + fn open_profile(&mut self, window: &mut Window, cx: &mut Context) { + Self::open_placeholder_panel( + &self.dock_area, + &mut self.profile, + "profile", + "Profile", + window, + cx, + ); + } + + fn open_relays(&mut self, window: &mut Window, cx: &mut Context) { + Self::open_placeholder_panel( + &self.dock_area, + &mut self.relays, + "relays", + "Relays", + window, + cx, + ); + } + + /// Opens one of the not-yet-built panels, reusing the already-open one. + fn open_placeholder_panel( + dock_area: &WeakEntity, + slot: &mut Option>, + name: &'static str, + title: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + if slot.as_ref().and_then(WeakEntity::upgrade).is_some() { + return; + } + + let panel = cx.new(|cx| PlaceholderPanel::new(name, title, cx)); + *slot = Some(panel.downgrade()); + + dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }) + .ok(); + } + fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context) { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); let pass_input = cx.new(|cx| { @@ -524,6 +575,7 @@ impl SidebarPanel { ) -> impl IntoElement { let name = profile.name(); let picture = profile.picture(); + let panel = cx.entity().downgrade(); title_bar_drag_handlers( h_flex() @@ -532,12 +584,44 @@ impl SidebarPanel { .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) .child( div().child( - Button::new("user").text().dropdown_caret(true).child( - h_flex() - .gap_1() - .child(Avatar::new(name.clone()).picture(picture)) - .child(div().text_xs().font_semibold().child(name)), - ), + Button::new("user") + .text() + .dropdown_caret(true) + .child( + h_flex() + .gap_1() + .child(Avatar::new(name.clone()).picture(picture)) + .child(div().text_xs().font_semibold().child(name)), + ) + .dropdown_menu(move |menu, _, _| { + menu.item(PopupMenuItem::new("View Profile").on_click({ + let panel = panel.clone(); + move |_, window, cx| { + let _ = panel.update(cx, |this, cx| { + this.open_profile(window, cx); + }); + } + })) + .item(PopupMenuItem::new("View Relays").on_click({ + let panel = panel.clone(); + move |_, window, cx| { + let _ = panel.update(cx, |this, cx| { + this.open_relays(window, cx); + }); + } + })) + .separator() + .item( + PopupMenuItem::element(|_, cx| { + div().text_color(cx.theme().danger).child("Exit") + }) + .on_click( + |_, window, cx| { + open_exit_dialog(window, cx); + }, + ), + ) + }), ), ), window, @@ -781,3 +865,26 @@ impl Render for SidebarPanel { ) } } + +fn open_exit_dialog(window: &mut Window, cx: &mut App) { + window.open_alert_dialog(cx, |alert, _, _| { + alert + .title("Exit Signed?") + .description( + "You will be signed out on this device and will need to sign in again on the next session.", + ) + .button_props( + DialogButtonProps::default() + .ok_text("Exit") + .ok_variant(ButtonVariant::Danger) + .show_cancel(true), + ) + .on_ok(|_, _, cx| { + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { + backend.sign_out(cx); + }); + true + }) + }); +} diff --git a/docs/TODO.md b/docs/TODO.md index 4640904..97f9187 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1 +1,4 @@ # TODO + +- Profile panel: the sidebar user menu's "View profile" opens an empty placeholder panel; build the actual profile view. +- Relays panel: the sidebar user menu's "View relays" opens an empty placeholder panel; build the actual relays view.