use std::time::Duration; use anyhow::{Error, anyhow}; use gpui::prelude::FluentBuilder; use gpui::{ AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, }; use nostr_connect::prelude::*; use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{Input, InputEvent, InputState}; use ui::{Disableable, StyledExt, WindowExtension, v_flex}; #[derive(Debug)] pub struct ImportIdentity { /// Secret key input key_input: Entity, /// Password input (if required) pass_input: Entity, /// Error message error: Entity>, /// Whether the user is currently loading loading: bool, /// Async tasks tasks: Vec>>, /// Input subscription _subscription: Option, } impl ImportIdentity { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let key_input = cx.new(|cx| InputState::new(window, cx)); let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let error = cx.new(|_| None); let input_subscription = cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { this.login(window, cx); }; }); Self { key_input, pass_input, error, loading: false, tasks: vec![], _subscription: Some(input_subscription), } } fn login(&mut self, window: &mut Window, cx: &mut Context) { let value = self.key_input.read(cx).value(); let password = self.pass_input.read(cx).value(); // Set loading state self.set_loading(true, cx); if value.starts_with("ncryptsec1") { self.ncryptsec(value, password, window, cx); return; } if value.starts_with("bunker://") { match NostrConnectUri::parse(value) { Ok(uri) => { self.bunker(uri, window, cx); } Err(e) => { self.set_error(e.to_string(), cx); } } return; } if let Ok(secret) = SecretKey::parse(&value) { let keys = Keys::new(secret); let nostr = NostrRegistry::global(cx); // Update the signer nostr.update(cx, |this, cx| { this.set_signer(keys, cx); }); } else { self.set_error("Invalid key", cx); } } fn ncryptsec(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context) where S: Into, { let nostr = NostrRegistry::global(cx); let content: String = content.into(); let password: String = pwd.into(); if password.is_empty() { self.set_error("Password is required", cx); return; } let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else { self.set_error("Secret Key is invalid", cx); return; }; // Decrypt in the background to ensure it doesn't block the UI let task = cx.background_spawn(async move { if let Ok(content) = enc.decrypt(&password) { Ok(Keys::new(content)) } else { Err(anyhow!("Invalid password")) } }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { match task.await { Ok(keys) => { nostr.update_in(cx, |this, window, cx| { this.set_signer(keys, cx); window.close_modal(cx); })?; } Err(e) => { this.update(cx, |this, cx| { this.set_error(e.to_string(), cx); })?; } } Ok(()) })); } fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let master_keys = nostr.read(cx).get_master_key(cx); let password = uri.to_string(); let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes()); cx.spawn_in(window, async move |_this, cx| { let keys = master_keys.await; let timeout = Duration::from_secs(30); // Construct the nostr connect signer let mut signer = NostrConnect::new(uri, keys, timeout, None)?; // Handle auth url with the default browser signer.auth_url_handler(CoopAuthUrlHandler); nostr.update(cx, |this, cx| { this.set_signer(signer, cx); cx.background_spawn(async move { save.await.ok() }).detach(); cx.notify(); }); Ok::<(), anyhow::Error>(()) }) .detach(); } fn set_loading(&mut self, status: bool, cx: &mut Context) { self.loading = status; cx.notify(); } fn set_error(&mut self, message: S, cx: &mut Context) where S: Into, { self.set_loading(false, cx); // Update error message self.error.update(cx, |this, cx| { *this = Some(message.into()); cx.notify(); }); // Clear the error message after 3 secs self.tasks.push(cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_secs(3)).await; this.update(cx, |this, cx| { this.error.update(cx, |this, cx| { *this = None; cx.notify(); }); })?; Ok(()) })); } } impl Render for ImportIdentity { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { const MSG: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login."; let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1"); let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password; v_flex() .size_full() .gap_2() .child( v_flex() .gap_1() .text_color(cx.theme().text_muted) .child("Continue with existing key or bunker connection") .child(Input::new(&self.key_input)), ) .when(require_password, |this| { this.child( v_flex() .gap_1() .text_color(cx.theme().text_muted) .child("Decrypt Password:") .child(Input::new(&self.pass_input)), ) }) .when(key_warning, |this| { this.child( div() .v_flex() .text_xs() .text_color(cx.theme().text_warning) .child(div().font_semibold().child("Warning")) .child(div().child(MSG)), ) }) .child( Button::new("login") .label("Continue") .primary() .loading(self.loading) .disabled(self.loading) .on_click(cx.listener(move |this, _, window, cx| { this.login(window, cx); })), ) .when_some(self.error.read(cx).as_ref(), |this, error| { this.child( div() .text_xs() .text_center() .text_color(cx.theme().text_danger) .child(error.clone()), ) }) } }