Files
coop/crates/workspace/src/dialogs/import.rs
2026-07-25 10:38:37 +07:00

260 lines
8.1 KiB
Rust

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<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Whether the user is currently loading
loading: bool,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Input subscription
_subscription: Option<Subscription>,
}
impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
{
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<Self>) {
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>) {
self.loading = status;
cx.notify();
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
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<Self>) -> 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()),
)
})
}
}