From 971a82df1b8793fb2c95909f3796b9011386018f Mon Sep 17 00:00:00 2001 From: reya Date: Wed, 25 Feb 2026 09:11:23 +0700 Subject: [PATCH] add backup panel --- assets/icons/user-key.svg | 2 +- crates/coop/src/panels/backup.rs | 194 +++++++++++++++++++++ crates/coop/src/panels/messaging_relays.rs | 11 +- crates/coop/src/panels/mod.rs | 1 + crates/coop/src/panels/relay_list.rs | 11 +- crates/coop/src/workspace.rs | 95 +++++++--- crates/state/src/blossom.rs | 58 ------ crates/ui/src/input/state.rs | 52 +++--- crates/ui/src/menu/menu_item.rs | 3 +- crates/ui/src/menu/popup_menu.rs | 4 +- 10 files changed, 308 insertions(+), 123 deletions(-) create mode 100644 crates/coop/src/panels/backup.rs diff --git a/assets/icons/user-key.svg b/assets/icons/user-key.svg index a982679..72d57dc 100644 --- a/assets/icons/user-key.svg +++ b/assets/icons/user-key.svg @@ -1,3 +1,3 @@ - + diff --git a/crates/coop/src/panels/backup.rs b/crates/coop/src/panels/backup.rs new file mode 100644 index 0000000..cf8bb48 --- /dev/null +++ b/crates/coop/src/panels/backup.rs @@ -0,0 +1,194 @@ +use std::time::Duration; + +use anyhow::Error; +use gpui::{ + div, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, +}; +use nostr_sdk::prelude::*; +use state::KEYRING; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::input::{InputState, TextInput}; +use ui::{divider, v_flex, IconName, Sizable, StyledExt}; + +const MSG: &str = "Store your account keys in a safe location. \ + You can restore your account or move to another client anytime you want."; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| BackupPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct BackupPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Public key input + npub_input: Entity, + + /// Secret key input + nsec_input: Entity, + + /// Copied status + copied: bool, + + /// Background tasks + tasks: Vec>>, +} + +impl BackupPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true)); + let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true)); + + // Run at the end of current cycle + cx.defer_in(window, |this, window, cx| { + this.load(window, cx); + }); + + Self { + name: "Backup".into(), + focus_handle: cx.focus_handle(), + npub_input, + nsec_input, + copied: false, + tasks: vec![], + } + } + + fn load(&mut self, window: &mut Window, cx: &mut Context) { + let keyring = cx.read_credentials(KEYRING); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Some((_, secret)) = keyring.await? { + let secret = SecretKey::from_slice(&secret)?; + let keys = Keys::new(secret); + + this.update_in(cx, |this, window, cx| { + this.npub_input.update(cx, |this, cx| { + this.set_value(keys.public_key().to_bech32().unwrap(), window, cx); + }); + + this.nsec_input.update(cx, |this, cx| { + this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx); + }); + })?; + } + + Ok(()) + })); + } + + fn copy_secret_key(&mut self, cx: &mut Context) { + let value = self.nsec_input.read(cx).value(); + let item = ClipboardItem::new_string(value.to_string()); + + // Copy to clipboard + cx.write_to_clipboard(item); + + // Set the copied status to true + self.set_copied(true, cx); + } + + fn set_copied(&mut self, status: bool, cx: &mut Context) { + self.copied = status; + cx.notify(); + + self.tasks.push(cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + // Clear the error message after a delay + this.update(cx, |this, cx| { + this.set_copied(false, cx); + })?; + + Ok(()) + })); + } +} + +impl Panel for BackupPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for BackupPanel {} + +impl Focusable for BackupPanel { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for BackupPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .p_3() + .gap_3() + .w_full() + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(MSG)), + ) + .child(divider(cx)) + .child( + v_flex() + .gap_2() + .flex_1() + .w_full() + .text_sm() + .child( + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Public Key:")), + ) + .child(TextInput::new(&self.npub_input).small().bordered(false)), + ) + .child( + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Secret Key:")), + ) + .child(TextInput::new(&self.nsec_input).small().bordered(false)), + ) + .child( + Button::new("copy") + .icon(IconName::Copy) + .label({ + if self.copied { + "Copied" + } else { + "Copy secret key" + } + }) + .primary() + .small() + .font_semibold() + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.copy_secret_key(cx); + })), + ), + ) + } +} diff --git a/crates/coop/src/panels/messaging_relays.rs b/crates/coop/src/panels/messaging_relays.rs index 4c0dd00..ad88825 100644 --- a/crates/coop/src/panels/messaging_relays.rs +++ b/crates/coop/src/panels/messaging_relays.rs @@ -144,16 +144,17 @@ impl MessagingRelayPanel { self.error = Some(error.into()); cx.notify(); - cx.spawn_in(window, async move |this, cx| { + self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; + // Clear the error message after a delay this.update(cx, |this, cx| { this.error = None; cx.notify(); - }) - .ok(); - }) - .detach(); + })?; + + Ok(()) + })); } fn set_updating(&mut self, updating: bool, cx: &mut Context) { diff --git a/crates/coop/src/panels/mod.rs b/crates/coop/src/panels/mod.rs index 259167c..a4c2e6f 100644 --- a/crates/coop/src/panels/mod.rs +++ b/crates/coop/src/panels/mod.rs @@ -1,3 +1,4 @@ +pub mod backup; pub mod connect; pub mod encryption_key; pub mod greeter; diff --git a/crates/coop/src/panels/relay_list.rs b/crates/coop/src/panels/relay_list.rs index 55d576d..e2709d5 100644 --- a/crates/coop/src/panels/relay_list.rs +++ b/crates/coop/src/panels/relay_list.rs @@ -163,16 +163,17 @@ impl RelayListPanel { self.error = Some(error.into()); cx.notify(); - cx.spawn_in(window, async move |this, cx| { + self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; + // Clear the error message after a delay this.update(cx, |this, cx| { this.error = None; cx.notify(); - }) - .ok(); - }) - .detach(); + })?; + + Ok(()) + })); } fn set_updating(&mut self, updating: bool, cx: &mut Context) { diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index 1cf1830..7de7051 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -20,7 +20,7 @@ use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::menu::DropdownMenu; use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; -use crate::panels::{encryption_key, greeter, messaging_relays, relay_list}; +use crate::panels::{backup, encryption_key, greeter, messaging_relays, profile, relay_list}; use crate::sidebar; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -30,11 +30,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = workspace, no_json)] enum Command { - ReloadRelayList, - OpenRelayPanel, - ReloadInbox, - OpenInboxPanel, - OpenEncryptionPanel, + ToggleTheme, + + RefreshRelayList, + RefreshMessagingRelays, + + ShowRelayList, + ShowMessaging, + ShowEncryption, + ShowProfile, + ShowSettings, + ShowBackup, } pub struct Workspace { @@ -181,7 +187,32 @@ impl Workspace { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { - Command::OpenEncryptionPanel => { + Command::ShowProfile => { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + + if let Some(public_key) = signer.public_key() { + self.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(profile::init(public_key, window, cx)), + DockPlacement::Right, + window, + cx, + ); + }); + } + } + Command::ShowBackup => { + self.dock.update(cx, |this, cx| { + this.add_panel( + Arc::new(backup::init(window, cx)), + DockPlacement::Right, + window, + cx, + ); + }); + } + Command::ShowEncryption => { let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); @@ -196,7 +227,7 @@ impl Workspace { }); } } - Command::OpenInboxPanel => { + Command::ShowMessaging => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(messaging_relays::init(window, cx)), @@ -206,7 +237,7 @@ impl Workspace { ); }); } - Command::OpenRelayPanel => { + Command::ShowRelayList => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(relay_list::init(window, cx)), @@ -216,18 +247,19 @@ impl Workspace { ); }); } - Command::ReloadInbox => { + Command::RefreshRelayList => { let nostr = NostrRegistry::global(cx); nostr.update(cx, |this, cx| { this.ensure_relay_list(cx); }); } - Command::ReloadRelayList => { + Command::RefreshMessagingRelays => { let chat = ChatRegistry::global(cx); chat.update(cx, |this, cx| { this.ensure_messaging_relays(cx); }); } + _ => {} } } @@ -252,12 +284,29 @@ impl Workspace { .compact() .transparent() .dropdown_menu(move |this, _window, _cx| { - this.label(profile.name()) + this.min_w(px(256.)) + .label(profile.name()) .separator() - .menu("Profile", Box::new(ClosePanel)) - .menu("Backup", Box::new(ClosePanel)) - .menu("Themes", Box::new(ClosePanel)) - .menu("Settings", Box::new(ClosePanel)) + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Backup", + IconName::UserKey, + Box::new(Command::ShowBackup), + ) + .menu_with_icon( + "Themes", + IconName::Sun, + Box::new(Command::ToggleTheme), + ) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) }), ) }) @@ -298,7 +347,7 @@ impl Workspace { .small() .ghost() .on_click(|_ev, window, cx| { - window.dispatch_action(Box::new(Command::OpenEncryptionPanel), cx); + window.dispatch_action(Box::new(Command::ShowEncryption), cx); }), ) .child( @@ -333,7 +382,7 @@ impl Workspace { this.min_w(px(260.)) .label("Messaging Relays") .menu_element_with_disabled( - Box::new(Command::OpenRelayPanel), + Box::new(Command::ShowRelayList), true, move |_window, cx| { let persons = PersonRegistry::global(cx); @@ -380,12 +429,12 @@ impl Workspace { .menu_with_icon( "Reload", IconName::Refresh, - Box::new(Command::ReloadInbox), + Box::new(Command::RefreshMessagingRelays), ) .menu_with_icon( "Update relays", IconName::Settings, - Box::new(Command::OpenInboxPanel), + Box::new(Command::ShowMessaging), ) }), ), @@ -421,7 +470,7 @@ impl Workspace { this.min_w(px(260.)) .label("Relays") .menu_element_with_disabled( - Box::new(Command::OpenRelayPanel), + Box::new(Command::ShowRelayList), true, move |_window, cx| { let nostr = NostrRegistry::global(cx); @@ -465,12 +514,12 @@ impl Workspace { .menu_with_icon( "Reload", IconName::Refresh, - Box::new(Command::ReloadRelayList), + Box::new(Command::RefreshRelayList), ) .menu_with_icon( "Update relay list", IconName::Settings, - Box::new(Command::OpenRelayPanel), + Box::new(Command::ShowRelayList), ) }), ), diff --git a/crates/state/src/blossom.rs b/crates/state/src/blossom.rs index 38dee46..7994187 100644 --- a/crates/state/src/blossom.rs +++ b/crates/state/src/blossom.rs @@ -25,61 +25,3 @@ pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result, - window: &mut Window, - cx: &mut Context, - ) { + pub fn set_value(&mut self, value: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { self.history.ignore = true; - let was_disabled = self.disabled; self.replace_text(value, window, cx); - self.disabled = was_disabled; self.history.ignore = false; // Ensure cursor to start when set text @@ -565,48 +561,50 @@ impl InputState { // Move scroll to top self.scroll_handle.set_offset(point(px(0.), px(0.))); - cx.notify(); } /// Insert text at the current cursor position. /// /// And the cursor will be moved to the end of inserted text. - pub fn insert( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { + pub fn insert(&mut self, text: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor())); self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); + self.disabled = was_disabled; } /// Replace text at the current cursor position. /// /// And the cursor will be moved to the end of replaced text. - pub fn replace( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { + pub fn replace(&mut self, text: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); self.replace_text_in_range_silent(None, &text, window, cx); self.selected_range = (self.selected_range.end..self.selected_range.end).into(); + self.disabled = was_disabled; } - fn replace_text( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { + fn replace_text(&mut self, text: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + let was_disabled = self.disabled; + self.disabled = false; let text: SharedString = text.into(); let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); self.replace_text_in_range_silent(Some(range), &text, window, cx); + self.disabled = was_disabled; } /// Set with password masked state. diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index dd14e20..48d5bd3 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -93,8 +93,7 @@ impl RenderOnce for MenuItemElement { .id(self.id) .group(&self.group_name) .gap_x_1() - .py_1() - .px_2() + .p_1() .text_sm() .text_color(cx.theme().text) .relative() diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index b92fd24..81506d1 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1073,7 +1073,7 @@ impl PopupMenu { let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(4.); - const INNER_PADDING: Pixels = px(8.); + const INNER_PADDING: Pixels = px(4.); let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); @@ -1143,7 +1143,7 @@ impl PopupMenu { .flex_1() .min_h(item_height) .items_center() - .gap_x_1() + .gap_x_2() .children(Self::render_icon( has_left_icon, is_left_check,