Redesign for the v1 stable release #3
@@ -1,425 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use common::BUNKER_TIMEOUT;
|
|
||||||
use dock::panel::{Panel, PanelEvent};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
|
||||||
};
|
|
||||||
use key_store::{KeyItem, KeyStore};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputEvent, InputState, TextInput};
|
|
||||||
use ui::notification::Notification;
|
|
||||||
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
|
||||||
cx.new(|cx| Login::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Login {
|
|
||||||
key_input: Entity<InputState>,
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
error: Entity<Option<SharedString>>,
|
|
||||||
countdown: Entity<Option<u64>>,
|
|
||||||
require_password: bool,
|
|
||||||
logging_in: bool,
|
|
||||||
|
|
||||||
/// Panel
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Login {
|
|
||||||
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 countdown = cx.new(|_| None);
|
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Subscribe to key input events and process login when the user presses enter
|
|
||||||
cx.subscribe_in(&key_input, window, |this, input, event, window, cx| {
|
|
||||||
match event {
|
|
||||||
InputEvent::PressEnter { .. } => {
|
|
||||||
this.login(window, cx);
|
|
||||||
}
|
|
||||||
InputEvent::Change => {
|
|
||||||
if input.read(cx).value().starts_with("ncryptsec1") {
|
|
||||||
this.require_password = true;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
key_input,
|
|
||||||
pass_input,
|
|
||||||
error,
|
|
||||||
countdown,
|
|
||||||
name: "Welcome Back".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
logging_in: false,
|
|
||||||
require_password: false,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.logging_in {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prevent duplicate login requests
|
|
||||||
self.set_logging_in(true, cx);
|
|
||||||
|
|
||||||
let value = self.key_input.read(cx).value();
|
|
||||||
let password = self.pass_input.read(cx).value();
|
|
||||||
|
|
||||||
if value.starts_with("bunker://") {
|
|
||||||
self.login_with_bunker(&value, window, cx);
|
|
||||||
} else if value.starts_with("ncryptsec1") {
|
|
||||||
self.login_with_password(&value, &password, cx);
|
|
||||||
} else if value.starts_with("nsec1") {
|
|
||||||
if let Ok(secret) = SecretKey::parse(&value) {
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
self.login_with_keys(keys, cx);
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid", cx);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.set_error("Invalid", cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Ok(uri) = NostrConnectUri::parse(content) else {
|
|
||||||
self.set_error("Bunker is not valid", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let app_keys = Keys::generate();
|
|
||||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
|
||||||
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
// signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
for i in (0..=BUNKER_TIMEOUT).rev() {
|
|
||||||
if i == 0 {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(None, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
} else {
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_countdown(Some(i), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
// Handle connection
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = signer.bunker_uri().await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(uri) => {
|
|
||||||
this.save_connection(&app_keys, &uri, window, cx);
|
|
||||||
this.connect(signer, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(Notification::error(e.to_string()), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_connection(
|
|
||||||
&mut self,
|
|
||||||
keys: &Keys,
|
|
||||||
uri: &NostrConnectUri,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
|
||||||
let username = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_bytes();
|
|
||||||
let mut clean_uri = uri.to_string();
|
|
||||||
|
|
||||||
// Clear the secret parameter in the URI if it exists
|
|
||||||
if let Some(s) = uri.secret() {
|
|
||||||
clean_uri = clean_uri.replace(s, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let user_url = KeyItem::User.to_string();
|
|
||||||
let bunker_url = KeyItem::Bunker.to_string();
|
|
||||||
let user_password = clean_uri.into_bytes();
|
|
||||||
|
|
||||||
// Write bunker uri to keyring for further connection
|
|
||||||
if let Err(e) = keystore
|
|
||||||
.write_credentials(&user_url, "bunker", &user_password, cx)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
this.update_in(cx, |_, window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the app keys for further connection
|
|
||||||
if let Err(e) = keystore
|
|
||||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
this.update_in(cx, |_, window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect(&mut self, signer: NostrConnect, cx: &mut Context<Self>) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
|
|
||||||
if pwd.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;
|
|
||||||
};
|
|
||||||
|
|
||||||
let password = pwd.to_owned();
|
|
||||||
|
|
||||||
// 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"))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(keys) => {
|
|
||||||
this.login_with_keys(keys, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
|
|
||||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
|
||||||
let username = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let bunker_url = KeyItem::User.to_string();
|
|
||||||
|
|
||||||
// Write the app keys for further connection
|
|
||||||
if let Err(e) = keystore
|
|
||||||
.write_credentials(&bunker_url, &username, &secret, cx)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_error(e.to_string(), cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.update(cx, |_this, cx| {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
S: Into<SharedString>,
|
|
||||||
{
|
|
||||||
// Reset the log in state
|
|
||||||
self.set_logging_in(false, cx);
|
|
||||||
|
|
||||||
// Reset the countdown
|
|
||||||
self.set_countdown(None, cx);
|
|
||||||
|
|
||||||
// Update error message
|
|
||||||
self.error.update(cx, |this, cx| {
|
|
||||||
*this = Some(message.into());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the error message after 3 secs
|
|
||||||
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();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.logging_in = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
|
||||||
self.countdown.update(cx, |this, cx| {
|
|
||||||
*this = i;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Login {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Login {}
|
|
||||||
|
|
||||||
impl Focusable for Login {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Login {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
v_flex()
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_96()
|
|
||||||
.gap_10()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.text_xl()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(SharedString::from("Continue with Private Key or Bunker")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.text_sm()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("nsec or bunker://")
|
|
||||||
.child(TextInput::new(&self.key_input)),
|
|
||||||
)
|
|
||||||
.when(self.require_password, |this| {
|
|
||||||
this.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Password:")
|
|
||||||
.child(TextInput::new(&self.pass_input)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new("login")
|
|
||||||
.label("Continue")
|
|
||||||
.primary()
|
|
||||||
.loading(self.logging_in)
|
|
||||||
.disabled(self.logging_in)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(format!(
|
|
||||||
"Approve connection request from your signer in {} seconds",
|
|
||||||
i
|
|
||||||
))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(error.clone()),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
|
||||||
use common::home_dir;
|
|
||||||
use gpui::{
|
|
||||||
div, App, AppContext, ClipboardItem, Context, Entity, IntoElement, ParentElement, Render,
|
|
||||||
SharedString, Styled, Task, Window,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt};
|
|
||||||
|
|
||||||
pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity<Backup> {
|
|
||||||
cx.new(|cx| Backup::new(keys, window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Backup {
|
|
||||||
pubkey_input: Entity<InputState>,
|
|
||||||
secret_input: Entity<InputState>,
|
|
||||||
error: Option<SharedString>,
|
|
||||||
copied: bool,
|
|
||||||
|
|
||||||
// Async operations
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Backup {
|
|
||||||
pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let Ok(npub) = keys.public_key.to_bech32();
|
|
||||||
let Ok(nsec) = keys.secret_key().to_bech32();
|
|
||||||
|
|
||||||
let pubkey_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.disabled(true)
|
|
||||||
.default_value(npub)
|
|
||||||
});
|
|
||||||
|
|
||||||
let secret_input = cx.new(|cx| {
|
|
||||||
InputState::new(window, cx)
|
|
||||||
.disabled(true)
|
|
||||||
.default_value(nsec)
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
pubkey_input,
|
|
||||||
secret_input,
|
|
||||||
error: None,
|
|
||||||
copied: false,
|
|
||||||
_tasks: smallvec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn backup(&self, window: &Window, cx: &Context<Self>) -> Task<Result<(), Error>> {
|
|
||||||
let dir = home_dir();
|
|
||||||
let path = cx.prompt_for_new_path(dir, Some("My Nostr Account"));
|
|
||||||
let nsec = self.secret_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match path.await {
|
|
||||||
Ok(Ok(Some(path))) => {
|
|
||||||
if let Err(e) = smol::fs::write(&path, nsec).await {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_error(e.to_string(), window, cx);
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
} else {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::error!("Failed to save backup keys");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Err(anyhow!("Failed to backup keys"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy(&mut self, value: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let item = ClipboardItem::new_string(value.into());
|
|
||||||
cx.write_to_clipboard(item);
|
|
||||||
|
|
||||||
self.set_copied(true, window, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.copied = status;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
// Reset the copied state after a delay
|
|
||||||
if status {
|
|
||||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_copied(false, window, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
E: Into<SharedString>,
|
|
||||||
{
|
|
||||||
self.error = Some(error.into());
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
// Clear the error message after a delay
|
|
||||||
self._tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
||||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.error = None;
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Backup {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you.";
|
|
||||||
const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account.";
|
|
||||||
const PK: &str = "Public Key is the address that others will use to find you.";
|
|
||||||
const SK: &str = "Secret Key provides access to your account.";
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from(DESCRIPTION))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.child(SharedString::from("Public Key:")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(TextInput::new(&self.pubkey_input).small())
|
|
||||||
.child(
|
|
||||||
Button::new("copy-pubkey")
|
|
||||||
.icon({
|
|
||||||
if self.copied {
|
|
||||||
IconName::CheckCircle
|
|
||||||
} else {
|
|
||||||
IconName::Copy
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ghost_alt()
|
|
||||||
.disabled(self.copied)
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.copy(this.pubkey_input.read(cx).value(), window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(PK)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.child(SharedString::from("Secret Key:")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(TextInput::new(&self.secret_input).small())
|
|
||||||
.child(
|
|
||||||
Button::new("copy-secret")
|
|
||||||
.icon({
|
|
||||||
if self.copied {
|
|
||||||
IconName::CheckCircle
|
|
||||||
} else {
|
|
||||||
IconName::Copy
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ghost_alt()
|
|
||||||
.disabled(self.copied)
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.copy(this.secret_input.read(cx).value(), window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(SK)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().danger_foreground)
|
|
||||||
.child(SharedString::from(WARN)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
use anyhow::{anyhow, Error};
|
|
||||||
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
|
|
||||||
use dock::panel::{Panel, PanelEvent};
|
|
||||||
use gpui::{
|
|
||||||
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, Window,
|
|
||||||
};
|
|
||||||
use gpui_tokio::Tokio;
|
|
||||||
use key_store::{KeyItem, KeyStore};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use settings::AppSettings;
|
|
||||||
use smol::fs;
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::input::{InputState, TextInput};
|
|
||||||
use ui::modal::ModalButtonProps;
|
|
||||||
use ui::{divider, v_flex, Disableable, IconName, Sizable, WindowExtension};
|
|
||||||
|
|
||||||
mod backup;
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
|
||||||
cx.new(|cx| NewAccount::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct NewAccount {
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
avatar_input: Entity<InputState>,
|
|
||||||
temp_keys: Entity<Keys>,
|
|
||||||
uploading: bool,
|
|
||||||
submitting: bool,
|
|
||||||
// Panel
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewAccount {
|
|
||||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let temp_keys = cx.new(|_| Keys::generate());
|
|
||||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
|
||||||
let avatar_input = cx.new(|cx| InputState::new(window, cx));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
name_input,
|
|
||||||
avatar_input,
|
|
||||||
temp_keys,
|
|
||||||
uploading: false,
|
|
||||||
submitting: false,
|
|
||||||
name: "Create a new identity".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.submitting(true, cx);
|
|
||||||
|
|
||||||
let keys = self.temp_keys.read(cx).clone();
|
|
||||||
let view = backup::init(&keys, window, cx);
|
|
||||||
let weak_view = view.downgrade();
|
|
||||||
let current_view = cx.entity().downgrade();
|
|
||||||
|
|
||||||
window.open_modal(cx, move |modal, _window, _cx| {
|
|
||||||
let weak_view = weak_view.clone();
|
|
||||||
let current_view = current_view.clone();
|
|
||||||
|
|
||||||
modal
|
|
||||||
.alert()
|
|
||||||
.title(SharedString::from(
|
|
||||||
"Backup to avoid losing access to your account",
|
|
||||||
))
|
|
||||||
.child(view.clone())
|
|
||||||
.button_props(ModalButtonProps::default().ok_text("Download"))
|
|
||||||
.on_ok(move |_, window, cx| {
|
|
||||||
weak_view
|
|
||||||
.update(cx, |this, cx| {
|
|
||||||
let view = current_view.clone();
|
|
||||||
let task = this.backup(window, cx);
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => {
|
|
||||||
view.update_in(cx, |this, window, cx| {
|
|
||||||
this.set_signer(window, cx);
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to backup: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
// true to close the modal
|
|
||||||
false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
let keys = self.temp_keys.read(cx).clone();
|
|
||||||
let username = keys.public_key().to_hex();
|
|
||||||
let secret = keys.secret_key().to_secret_hex().into_bytes();
|
|
||||||
|
|
||||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
|
||||||
let name = self.name_input.read(cx).value().to_string();
|
|
||||||
let mut metadata = Metadata::new().display_name(name.clone()).name(name);
|
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&avatar) {
|
|
||||||
metadata = metadata.picture(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close all modals if available
|
|
||||||
window.close_all_modals(cx);
|
|
||||||
|
|
||||||
// Set the client's signer with the current keys
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
|
||||||
let signer = keys.clone();
|
|
||||||
let nip65_relays = default_nip65_relays();
|
|
||||||
let nip17_relays = default_nip17_relays();
|
|
||||||
|
|
||||||
// Construct a NIP-65 event
|
|
||||||
let event = EventBuilder::new(Kind::RelayList, "")
|
|
||||||
.tags(
|
|
||||||
nip65_relays
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(|(url, metadata)| Tag::relay_metadata(url, metadata)),
|
|
||||||
)
|
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set NIP-65 relays
|
|
||||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
|
||||||
|
|
||||||
// Extract only write relays
|
|
||||||
let write_relays: Vec<RelayUrl> = nip65_relays
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, metadata)| {
|
|
||||||
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
|
|
||||||
Some(url.to_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Ensure relays are connected
|
|
||||||
for url in write_relays.iter() {
|
|
||||||
client.add_relay(url).await?;
|
|
||||||
client.connect_relay(url).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a NIP-17 event
|
|
||||||
let event = EventBuilder::new(Kind::InboxRelays, "")
|
|
||||||
.tags(nip17_relays.iter().cloned().map(Tag::relay))
|
|
||||||
.sign(&signer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set NIP-17 relays
|
|
||||||
client.send_event_to(&write_relays, &event).await?;
|
|
||||||
|
|
||||||
// Construct a metadata event
|
|
||||||
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
|
|
||||||
|
|
||||||
// Send metadata event to both write relays and bootstrap relays
|
|
||||||
client.send_event_to(&write_relays, &event).await?;
|
|
||||||
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?;
|
|
||||||
|
|
||||||
// Update the client's signer with the current keys
|
|
||||||
client.set_signer(keys).await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let url = KeyItem::User.to_string();
|
|
||||||
|
|
||||||
// Write the app keys for further connection
|
|
||||||
keystore
|
|
||||||
.write_credentials(&url, &username, &secret, cx)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
if let Err(e) = task.await {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
this.submitting(false, cx);
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.uploading(true, cx);
|
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
|
|
||||||
// Get the user's configured NIP96 server
|
|
||||||
let nip96_server = AppSettings::get_file_server(cx);
|
|
||||||
|
|
||||||
// Open native file dialog
|
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
|
||||||
files: true,
|
|
||||||
directories: false,
|
|
||||||
multiple: false,
|
|
||||||
prompt: None,
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = Tokio::spawn(cx, async move {
|
|
||||||
match paths.await {
|
|
||||||
Ok(Ok(Some(mut paths))) => {
|
|
||||||
if let Some(path) = paths.pop() {
|
|
||||||
let file = fs::read(path).await?;
|
|
||||||
let url = nip96_upload(&client, &nip96_server, file).await?;
|
|
||||||
|
|
||||||
Ok(url)
|
|
||||||
} else {
|
|
||||||
Err(anyhow!("Path not found"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(anyhow!("Error")),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(Ok(url)) => {
|
|
||||||
this.avatar_input.update(cx, |this, cx| {
|
|
||||||
this.set_value(url.to_string(), window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to upload avatar: {e}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.uploading(false, cx);
|
|
||||||
})
|
|
||||||
.expect("Entity has been released");
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.submitting = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.uploading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for NewAccount {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for NewAccount {}
|
|
||||||
|
|
||||||
impl Focusable for NewAccount {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for NewAccount {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let avatar = self.avatar_input.read(cx).value();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.w_96()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.h_40()
|
|
||||||
.w_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_4()
|
|
||||||
.child(Avatar::new(avatar).size(rems(4.25)))
|
|
||||||
.child(
|
|
||||||
Button::new("upload")
|
|
||||||
.icon(IconName::PlusCircle)
|
|
||||||
.label("Add an avatar")
|
|
||||||
.xsmall()
|
|
||||||
.ghost()
|
|
||||||
.rounded()
|
|
||||||
.disabled(self.uploading)
|
|
||||||
//.loading(self.uploading)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.upload(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from("What should people call you?"))
|
|
||||||
.child(
|
|
||||||
TextInput::new(&self.name_input)
|
|
||||||
.disabled(self.submitting)
|
|
||||||
.small(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(divider(cx))
|
|
||||||
.child(
|
|
||||||
Button::new("submit")
|
|
||||||
.label("Continue")
|
|
||||||
.primary()
|
|
||||||
.loading(self.submitting)
|
|
||||||
.disabled(self.submitting || self.uploading)
|
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
|
||||||
this.create(window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
123
crates/coop/src/panels/connect.rs
Normal file
123
crates/coop/src/panels/connect.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use common::TextUtils;
|
||||||
|
use dock::panel::{Panel, PanelEvent};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||||
|
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::NostrRegistry;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{v_flex, StyledExt, WindowExtension};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
|
||||||
|
cx.new(|cx| ConnectPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConnectPanel {
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// QR Code
|
||||||
|
qr_code: Option<Arc<Image>>,
|
||||||
|
|
||||||
|
/// Background tasks
|
||||||
|
_tasks: SmallVec<[Task<()>; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectPanel {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let weak_state = nostr.downgrade();
|
||||||
|
let (signer, uri) = nostr.read(cx).client_connect(None);
|
||||||
|
|
||||||
|
// Generate a QR code for quick connection
|
||||||
|
let qr_code = uri.to_string().to_qr();
|
||||||
|
|
||||||
|
let mut tasks = smallvec![];
|
||||||
|
|
||||||
|
tasks.push(
|
||||||
|
// Wait for nostr connect
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
let result = signer.bunker_uri().await;
|
||||||
|
|
||||||
|
weak_state
|
||||||
|
.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(uri) => {
|
||||||
|
this.persist_bunker(uri, cx);
|
||||||
|
this.set_signer(signer, true, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: "Nostr Connect".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
qr_code,
|
||||||
|
_tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for ConnectPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for ConnectPanel {}
|
||||||
|
|
||||||
|
impl Focusable for ConnectPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ConnectPanel {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.justify_center()
|
||||||
|
.items_center()
|
||||||
|
.text_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.25))
|
||||||
|
.child(SharedString::from("Continue with Nostr Connect")),
|
||||||
|
)
|
||||||
|
.child(div().text_sm().text_color(cx.theme().text_muted).child(
|
||||||
|
SharedString::from("Use Nostr Connect apps to scan the code"),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.when_some(self.qr_code.as_ref(), |this, qr| {
|
||||||
|
this.child(
|
||||||
|
img(qr.clone())
|
||||||
|
.size(px(256.))
|
||||||
|
.rounded(cx.theme().radius_lg)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,25 +9,28 @@ use theme::ActiveTheme;
|
|||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Greeter> {
|
use crate::panels::{connect, import};
|
||||||
cx.new(|cx| Greeter::new(window, cx))
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
|
||||||
|
cx.new(|cx| GreeterPanel::new(window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Greeter {
|
pub struct GreeterPanel {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Greeter {
|
impl GreeterPanel {
|
||||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: "Greeter".into(),
|
name: "Onboarding".into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for Greeter {
|
impl Panel for GreeterPanel {
|
||||||
fn panel_id(&self) -> SharedString {
|
fn panel_id(&self) -> SharedString {
|
||||||
self.name.clone()
|
self.name.clone()
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ impl Panel for Greeter {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_xs()
|
||||||
.text_color(cx.theme().text_muted)
|
.text_color(cx.theme().text_muted)
|
||||||
.child(self.name.clone()),
|
.child(self.name.clone()),
|
||||||
)
|
)
|
||||||
@@ -51,15 +54,15 @@ impl Panel for Greeter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Greeter {}
|
impl EventEmitter<PanelEvent> for GreeterPanel {}
|
||||||
|
|
||||||
impl Focusable for Greeter {
|
impl Focusable for GreeterPanel {
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
self.focus_handle.clone()
|
self.focus_handle.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Greeter {
|
impl Render for GreeterPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
const TITLE: &str = "Welcome to Coop!";
|
const TITLE: &str = "Welcome to Coop!";
|
||||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||||
@@ -126,14 +129,28 @@ impl Render for Greeter {
|
|||||||
.icon(Icon::new(IconName::Door))
|
.icon(Icon::new(IconName::Door))
|
||||||
.label("Connect account via Nostr Connect")
|
.label("Connect account via Nostr Connect")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small(),
|
.small()
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
Workspace::add_panel(
|
||||||
|
connect::init(window, cx),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("import")
|
Button::new("import")
|
||||||
.icon(Icon::new(IconName::Usb))
|
.icon(Icon::new(IconName::Usb))
|
||||||
.label("Import a secret key or bunker")
|
.label("Import a secret key or bunker")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small(),
|
.small()
|
||||||
|
.on_click(move |_ev, window, cx| {
|
||||||
|
Workspace::add_panel(
|
||||||
|
import::init(window, cx),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
344
crates/coop/src/panels/import.rs
Normal file
344
crates/coop/src/panels/import.rs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use dock::panel::{Panel, PanelEvent};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
||||||
|
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||||
|
};
|
||||||
|
use nostr_connect::prelude::*;
|
||||||
|
use smallvec::{smallvec, SmallVec};
|
||||||
|
use state::{CoopAuthUrlHandler, NostrRegistry};
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::input::{InputEvent, InputState, TextInput};
|
||||||
|
use ui::notification::Notification;
|
||||||
|
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
|
||||||
|
|
||||||
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
|
||||||
|
cx.new(|cx| ImportPanel::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ImportPanel {
|
||||||
|
name: SharedString,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
|
||||||
|
/// Secret key input
|
||||||
|
key_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Password input (if required)
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
|
||||||
|
/// Error message
|
||||||
|
error: Entity<Option<SharedString>>,
|
||||||
|
|
||||||
|
/// Countdown timer for nostr connect
|
||||||
|
countdown: Entity<Option<u64>>,
|
||||||
|
|
||||||
|
/// Whether the user is currently logging in
|
||||||
|
logging_in: bool,
|
||||||
|
|
||||||
|
/// Event subscriptions
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportPanel {
|
||||||
|
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 countdown = cx.new(|_| None);
|
||||||
|
|
||||||
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
subscriptions.push(
|
||||||
|
// Subscribe to key input events and process login when the user presses enter
|
||||||
|
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,
|
||||||
|
countdown,
|
||||||
|
name: "Import".into(),
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
logging_in: false,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.logging_in {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Prevent duplicate login requests
|
||||||
|
self.set_logging_in(true, cx);
|
||||||
|
|
||||||
|
let value = self.key_input.read(cx).value();
|
||||||
|
let password = self.pass_input.read(cx).value();
|
||||||
|
|
||||||
|
if value.starts_with("bunker://") {
|
||||||
|
self.login_with_bunker(&value, window, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.starts_with("ncryptsec1") {
|
||||||
|
self.login_with_password(&value, &password, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(secret) = SecretKey::parse(&value) {
|
||||||
|
let keys = Keys::new(secret);
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, true, cx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.set_error("Invalid", cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let Ok(uri) = NostrConnectUri::parse(content) else {
|
||||||
|
self.set_error("Bunker is not valid", cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let weak_state = nostr.downgrade();
|
||||||
|
|
||||||
|
let app_keys = nostr.read(cx).app_keys();
|
||||||
|
let timeout = Duration::from_secs(30);
|
||||||
|
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
|
||||||
|
|
||||||
|
// Handle auth url with the default browser
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
for i in (0..=30).rev() {
|
||||||
|
if i == 0 {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(None, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
} else {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.set_countdown(Some(i), cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
// Handle connection
|
||||||
|
cx.spawn_in(window, async move |_this, cx| {
|
||||||
|
let result = signer.bunker_uri().await;
|
||||||
|
|
||||||
|
weak_state
|
||||||
|
.update_in(cx, |this, window, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(uri) => {
|
||||||
|
this.persist_bunker(uri, cx);
|
||||||
|
this.set_signer(signer, true, cx);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
window.push_notification(Notification::error(e.to_string()), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context<Self>) {
|
||||||
|
if pwd.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = pwd.to_owned();
|
||||||
|
|
||||||
|
// 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"))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
let result = task.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
match result {
|
||||||
|
Ok(keys) => {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
nostr.update(cx, |this, cx| {
|
||||||
|
this.set_signer(keys, true, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.set_error(e.to_string(), cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
S: Into<SharedString>,
|
||||||
|
{
|
||||||
|
// Reset the log in state
|
||||||
|
self.set_logging_in(false, cx);
|
||||||
|
|
||||||
|
// Reset the countdown
|
||||||
|
self.set_countdown(None, cx);
|
||||||
|
|
||||||
|
// Update error message
|
||||||
|
self.error.update(cx, |this, cx| {
|
||||||
|
*this = Some(message.into());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the error message after 3 secs
|
||||||
|
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();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||||
|
self.logging_in = status;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_countdown(&mut self, i: Option<u64>, cx: &mut Context<Self>) {
|
||||||
|
self.countdown.update(cx, |this, cx| {
|
||||||
|
*this = i;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for ImportPanel {
|
||||||
|
fn panel_id(&self) -> SharedString {
|
||||||
|
self.name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self, _cx: &App) -> AnyElement {
|
||||||
|
self.name.clone().into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<PanelEvent> for ImportPanel {}
|
||||||
|
|
||||||
|
impl Focusable for ImportPanel {
|
||||||
|
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ImportPanel {
|
||||||
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_center()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.25))
|
||||||
|
.child(SharedString::from("Import a Secret Key or Bunker")),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.w_96()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("nsec or bunker://")
|
||||||
|
.child(TextInput::new(&self.key_input)),
|
||||||
|
)
|
||||||
|
.when(
|
||||||
|
self.key_input.read(cx).value().starts_with("ncryptsec1"),
|
||||||
|
|this| {
|
||||||
|
this.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Password:")
|
||||||
|
.child(TextInput::new(&self.pass_input)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("login")
|
||||||
|
.label("Continue")
|
||||||
|
.primary()
|
||||||
|
.loading(self.logging_in)
|
||||||
|
.disabled(self.logging_in)
|
||||||
|
.on_click(cx.listener(move |this, _, window, cx| {
|
||||||
|
this.login(window, cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.when_some(self.countdown.read(cx).as_ref(), |this, i| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::from(format!(
|
||||||
|
"Approve connection request from your signer in {} seconds",
|
||||||
|
i
|
||||||
|
))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.error.read(cx).as_ref(), |this, error| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_center()
|
||||||
|
.text_color(cx.theme().danger_foreground)
|
||||||
|
.child(error.clone()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
pub mod connect;
|
||||||
pub mod greeter;
|
pub mod greeter;
|
||||||
|
pub mod import;
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use common::BUNKER_TIMEOUT;
|
|
||||||
use dock::panel::{Panel, PanelEvent};
|
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{
|
|
||||||
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
|
||||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
|
|
||||||
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
|
||||||
Window,
|
|
||||||
};
|
|
||||||
use key_store::{Credential, KeyItem, KeyStore};
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use person::PersonRegistry;
|
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
use state::NostrRegistry;
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::avatar::Avatar;
|
|
||||||
use ui::button::{Button, ButtonVariants};
|
|
||||||
use ui::indicator::Indicator;
|
|
||||||
use ui::{h_flex, v_flex, Sizable, StyledExt, WindowExtension};
|
|
||||||
|
|
||||||
use crate::actions::reset;
|
|
||||||
|
|
||||||
pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
|
||||||
cx.new(|cx| Startup::new(cre, window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Startup
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Startup {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
|
|
||||||
/// Local user credentials
|
|
||||||
credential: Credential,
|
|
||||||
|
|
||||||
/// Whether the loadng is in progress
|
|
||||||
loading: bool,
|
|
||||||
|
|
||||||
/// Image cache
|
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
|
||||||
|
|
||||||
/// Event subscriptions
|
|
||||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
|
||||||
|
|
||||||
/// Background tasks
|
|
||||||
_tasks: SmallVec<[Task<()>; 1]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Startup {
|
|
||||||
fn new(credential: Credential, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let tasks = smallvec![];
|
|
||||||
let mut subscriptions = smallvec![];
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
// Clear the local state when user closes the account panel
|
|
||||||
cx.on_release_in(window, move |this, window, cx| {
|
|
||||||
this.image_cache.update(cx, |this, cx| {
|
|
||||||
this.clear(window, cx);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
credential,
|
|
||||||
loading: false,
|
|
||||||
name: "Onboarding".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
image_cache: RetainAllImageCache::new(cx),
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
_tasks: tasks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.set_loading(true, cx);
|
|
||||||
|
|
||||||
let secret = self.credential.secret();
|
|
||||||
|
|
||||||
// Try to login with bunker
|
|
||||||
if secret.starts_with("bunker://") {
|
|
||||||
match NostrConnectUri::parse(secret) {
|
|
||||||
Ok(uri) => {
|
|
||||||
self.login_with_bunker(uri, window, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
self.set_loading(false, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fall back to login with keys
|
|
||||||
match SecretKey::parse(secret) {
|
|
||||||
Ok(secret) => {
|
|
||||||
self.login_with_keys(secret, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
self.set_loading(false, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_bunker(
|
|
||||||
&mut self,
|
|
||||||
uri: NostrConnectUri,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
let client = nostr.read(cx).client();
|
|
||||||
let keystore = KeyStore::global(cx).read(cx).backend();
|
|
||||||
|
|
||||||
// Handle connection in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let result = keystore
|
|
||||||
.read_credentials(&KeyItem::Bunker.to_string(), cx)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
match result {
|
|
||||||
Ok(Some((_, content))) => {
|
|
||||||
let secret = SecretKey::from_slice(&content).unwrap();
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
let timeout = Duration::from_secs(BUNKER_TIMEOUT);
|
|
||||||
let mut signer = NostrConnect::new(uri, keys, timeout, None).unwrap();
|
|
||||||
|
|
||||||
// Handle auth url with the default browser
|
|
||||||
// signer.auth_url_handler(CoopAuthUrlHandler);
|
|
||||||
|
|
||||||
// Connect to the remote signer
|
|
||||||
this._tasks.push(
|
|
||||||
// Handle connection in the background
|
|
||||||
cx.spawn_in(window, async move |this, cx| {
|
|
||||||
match signer.bunker_uri().await {
|
|
||||||
Ok(_) => {
|
|
||||||
client.set_signer(signer).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
this.update_in(cx, |this, window, cx| {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
window.push_notification(
|
|
||||||
"You must allow Coop access to the keyring to continue.",
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
window.push_notification(e.to_string(), cx);
|
|
||||||
this.set_loading(false, cx);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context<Self>) {
|
|
||||||
let keys = Keys::new(secret);
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
|
||||||
|
|
||||||
nostr.update(cx, |this, cx| {
|
|
||||||
this.set_signer(keys, cx);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
|
||||||
self.loading = status;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Startup {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, _cx: &App) -> AnyElement {
|
|
||||||
self.name.clone().into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Startup {}
|
|
||||||
|
|
||||||
impl Focusable for Startup {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Startup {
|
|
||||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let persons = PersonRegistry::global(cx);
|
|
||||||
let bunker = self.credential.secret().starts_with("bunker://");
|
|
||||||
let profile = persons.read(cx).get(&self.credential.public_key(), cx);
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.image_cache(self.image_cache.clone())
|
|
||||||
.relative()
|
|
||||||
.size_full()
|
|
||||||
.gap_10()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_16()
|
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xl()
|
|
||||||
.font_semibold()
|
|
||||||
.line_height(relative(1.3))
|
|
||||||
.child(SharedString::from("Welcome to Coop")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Chat Freely, Stay Private on Nostr.",
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("account")
|
|
||||||
.h_10()
|
|
||||||
.w_72()
|
|
||||||
.bg(cx.theme().elevated_surface_background)
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.text_sm()
|
|
||||||
.when(self.loading, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(Indicator::new().small()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(!self.loading, |this| {
|
|
||||||
let avatar = profile.avatar();
|
|
||||||
let name = profile.name();
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.h_full()
|
|
||||||
.justify_center()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(Avatar::new(avatar).size(rems(1.5)))
|
|
||||||
.child(div().pb_px().font_semibold().child(name)),
|
|
||||||
)
|
|
||||||
.child(div().when(bunker, |this| {
|
|
||||||
let label = SharedString::from("Nostr Connect");
|
|
||||||
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.py_0p5()
|
|
||||||
.px_2()
|
|
||||||
.text_xs()
|
|
||||||
.bg(cx.theme().secondary_active)
|
|
||||||
.text_color(cx.theme().secondary_foreground)
|
|
||||||
.rounded_full()
|
|
||||||
.child(label),
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.text_color(cx.theme().text)
|
|
||||||
.active(|this| {
|
|
||||||
this.text_color(cx.theme().element_foreground)
|
|
||||||
.bg(cx.theme().element_active)
|
|
||||||
})
|
|
||||||
.hover(|this| {
|
|
||||||
this.text_color(cx.theme().element_foreground)
|
|
||||||
.bg(cx.theme().element_hover)
|
|
||||||
})
|
|
||||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
|
||||||
this.login(window, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(Button::new("logout").label("Sign out").ghost().on_click(
|
|
||||||
|_, _window, cx| {
|
|
||||||
reset(cx);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
use dock::panel::{Panel, PanelEvent};
|
|
||||||
use gpui::{
|
|
||||||
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
|
||||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
|
||||||
StatefulInteractiveElement, Styled, Window,
|
|
||||||
};
|
|
||||||
use theme::ActiveTheme;
|
|
||||||
use ui::{h_flex, v_flex, StyledExt};
|
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
|
|
||||||
cx.new(|cx| Welcome::new(window, cx))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Welcome {
|
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Welcome {
|
|
||||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
|
||||||
Self {
|
|
||||||
name: "Welcome".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for Welcome {
|
|
||||||
fn panel_id(&self) -> SharedString {
|
|
||||||
self.name.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self, cx: &App) -> AnyElement {
|
|
||||||
h_flex()
|
|
||||||
.gap_1p5()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_4()
|
|
||||||
.text_color(cx.theme().text_muted),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(self.name.clone()),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<PanelEvent> for Welcome {}
|
|
||||||
|
|
||||||
impl Focusable for Welcome {
|
|
||||||
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for Welcome {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
svg()
|
|
||||||
.path("brand/coop.svg")
|
|
||||||
.size_12()
|
|
||||||
.text_color(cx.theme().elevated_surface_background),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.text_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("coop on nostr")),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.id("version")
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.text_xs()
|
|
||||||
.on_click(|_, _window, cx| {
|
|
||||||
cx.open_url("https://github.com/lumehq/coop/releases");
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ use chat::{ChatEvent, ChatRegistry};
|
|||||||
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
use chat_ui::{CopyPublicKey, OpenPublicKey};
|
||||||
use common::DEFAULT_SIDEBAR_WIDTH;
|
use common::DEFAULT_SIDEBAR_WIDTH;
|
||||||
use dock::dock::DockPlacement;
|
use dock::dock::DockPlacement;
|
||||||
|
use dock::panel::PanelView;
|
||||||
use dock::{ClosePanel, DockArea, DockItem};
|
use dock::{ClosePanel, DockArea, DockItem};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement,
|
div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement,
|
||||||
@@ -108,6 +109,21 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_panel<P>(panel: P, window: &mut Window, cx: &mut App)
|
||||||
|
where
|
||||||
|
P: PanelView,
|
||||||
|
{
|
||||||
|
if let Some(root) = window.root::<Root>().flatten() {
|
||||||
|
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
|
||||||
|
workspace.update(cx, |this, cx| {
|
||||||
|
this.dock.update(cx, |this, cx| {
|
||||||
|
this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn set_layout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let weak_dock = self.dock.downgrade();
|
let weak_dock = self.dock.downgrade();
|
||||||
|
|
||||||
@@ -340,89 +356,6 @@ impl Workspace {
|
|||||||
|
|
||||||
Some(ids)
|
Some(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let chat = ChatRegistry::global(cx);
|
|
||||||
let status = chat.read(cx).loading();
|
|
||||||
|
|
||||||
let auto_update = AutoUpdater::global(cx);
|
|
||||||
let relay_auth = RelayAuth::global(cx);
|
|
||||||
let pending_requests = relay_auth.read(cx).pending_requests(cx);
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.map(|this| match auto_update.read(cx).status.as_ref() {
|
|
||||||
AutoUpdateStatus::Checking => this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Checking for Coop updates...")),
|
|
||||||
),
|
|
||||||
AutoUpdateStatus::Installing => this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Installing updates...")),
|
|
||||||
),
|
|
||||||
AutoUpdateStatus::Errored { msg } => this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from(msg.as_ref())),
|
|
||||||
),
|
|
||||||
AutoUpdateStatus::Updated => this.child(
|
|
||||||
div()
|
|
||||||
.id("restart")
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child(SharedString::from("Updated. Click to restart"))
|
|
||||||
.on_click(|_ev, _window, cx| {
|
|
||||||
cx.restart();
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
_ => this.child(div()),
|
|
||||||
})
|
|
||||||
.when(pending_requests > 0, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.id("requests")
|
|
||||||
.h_6()
|
|
||||||
.px_2()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.text_xs()
|
|
||||||
.rounded_full()
|
|
||||||
.bg(cx.theme().warning_background)
|
|
||||||
.text_color(cx.theme().warning_foreground)
|
|
||||||
.hover(|this| this.bg(cx.theme().warning_hover))
|
|
||||||
.active(|this| this.bg(cx.theme().warning_active))
|
|
||||||
.child(SharedString::from(format!(
|
|
||||||
"You have {} pending authentication requests",
|
|
||||||
pending_requests
|
|
||||||
)))
|
|
||||||
.on_click(move |_ev, window, cx| {
|
|
||||||
relay_auth.update(cx, |this, cx| {
|
|
||||||
this.re_ask(window, cx);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(status, |this| {
|
|
||||||
this.child(deferred(
|
|
||||||
h_flex()
|
|
||||||
.px_2()
|
|
||||||
.h_6()
|
|
||||||
.gap_1()
|
|
||||||
.text_xs()
|
|
||||||
.rounded_full()
|
|
||||||
.bg(cx.theme().surface_background)
|
|
||||||
.child(SharedString::from(
|
|
||||||
"Getting messages. This may take a while...",
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Workspace {
|
impl Render for Workspace {
|
||||||
|
|||||||
Reference in New Issue
Block a user