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,