diff --git a/crates/coop/src/login/mod.rs b/crates/coop/src/login/mod.rs deleted file mode 100644 index 39154bb..0000000 --- a/crates/coop/src/login/mod.rs +++ /dev/null @@ -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 { - cx.new(|cx| Login::new(window, cx)) -} - -#[derive(Debug)] -pub struct Login { - key_input: Entity, - pass_input: Entity, - error: Entity>, - countdown: Entity>, - 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 { - 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) { - 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) { - 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, - ) { - 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) { - 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) { - 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) { - 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(&mut self, message: S, cx: &mut Context) - where - S: Into, - { - // 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.logging_in = status; - cx.notify(); - } - - fn set_countdown(&mut self, i: Option, cx: &mut Context) { - 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 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) -> 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()), - ) - }), - ), - ) - } -} diff --git a/crates/coop/src/new_identity/backup.rs b/crates/coop/src/new_identity/backup.rs deleted file mode 100644 index f5a52b8..0000000 --- a/crates/coop/src/new_identity/backup.rs +++ /dev/null @@ -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 { - cx.new(|cx| Backup::new(keys, window, cx)) -} - -#[derive(Debug)] -pub struct Backup { - pubkey_input: Entity, - secret_input: Entity, - error: Option, - copied: bool, - - // Async operations - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Backup { - pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context) -> 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) -> Task> { - 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, window: &mut Window, cx: &mut Context) { - 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.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(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - 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) -> 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)), - ) - } -} diff --git a/crates/coop/src/new_identity/mod.rs b/crates/coop/src/new_identity/mod.rs deleted file mode 100644 index 4c69475..0000000 --- a/crates/coop/src/new_identity/mod.rs +++ /dev/null @@ -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 { - cx.new(|cx| NewAccount::new(window, cx)) -} - -#[derive(Debug)] -pub struct NewAccount { - name_input: Entity, - avatar_input: Entity, - temp_keys: Entity, - uploading: bool, - submitting: bool, - // Panel - name: SharedString, - focus_handle: FocusHandle, -} - -impl NewAccount { - fn new(window: &mut Window, cx: &mut Context) -> 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.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) { - 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> = 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 = 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.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.submitting = status; - cx.notify(); - } - - fn uploading(&mut self, status: bool, cx: &mut Context) { - 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 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) -> 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); - })), - ), - ) - } -} diff --git a/crates/coop/src/panels/connect.rs b/crates/coop/src/panels/connect.rs new file mode 100644 index 0000000..993a77b --- /dev/null +++ b/crates/coop/src/panels/connect.rs @@ -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 { + cx.new(|cx| ConnectPanel::new(window, cx)) +} + +pub struct ConnectPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// QR Code + qr_code: Option>, + + /// Background tasks + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl ConnectPanel { + fn new(window: &mut Window, cx: &mut Context) -> 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 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) -> 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), + ) + }) + } +} diff --git a/crates/coop/src/panels/greeter.rs b/crates/coop/src/panels/greeter.rs index 58a80f8..e351efb 100644 --- a/crates/coop/src/panels/greeter.rs +++ b/crates/coop/src/panels/greeter.rs @@ -9,25 +9,28 @@ use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Greeter::new(window, cx)) +use crate::panels::{connect, import}; +use crate::workspace::Workspace; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| GreeterPanel::new(window, cx)) } -pub struct Greeter { +pub struct GreeterPanel { name: SharedString, focus_handle: FocusHandle, } -impl Greeter { +impl GreeterPanel { fn new(_window: &mut Window, cx: &mut App) -> Self { Self { - name: "Greeter".into(), + name: "Onboarding".into(), focus_handle: cx.focus_handle(), } } } -impl Panel for Greeter { +impl Panel for GreeterPanel { fn panel_id(&self) -> SharedString { self.name.clone() } @@ -43,7 +46,7 @@ impl Panel for Greeter { ) .child( div() - .text_sm() + .text_xs() .text_color(cx.theme().text_muted) .child(self.name.clone()), ) @@ -51,15 +54,15 @@ impl Panel for Greeter { } } -impl EventEmitter for Greeter {} +impl EventEmitter for GreeterPanel {} -impl Focusable for Greeter { +impl Focusable for GreeterPanel { fn focus_handle(&self, _: &App) -> gpui::FocusHandle { self.focus_handle.clone() } } -impl Render for Greeter { +impl Render for GreeterPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { const TITLE: &str = "Welcome to Coop!"; const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; @@ -126,14 +129,28 @@ impl Render for Greeter { .icon(Icon::new(IconName::Door)) .label("Connect account via Nostr Connect") .ghost() - .small(), + .small() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + connect::init(window, cx), + window, + cx, + ); + }), ) .child( Button::new("import") .icon(Icon::new(IconName::Usb)) .label("Import a secret key or bunker") .ghost() - .small(), + .small() + .on_click(move |_ev, window, cx| { + Workspace::add_panel( + import::init(window, cx), + window, + cx, + ); + }), ) }) .child( diff --git a/crates/coop/src/panels/import.rs b/crates/coop/src/panels/import.rs new file mode 100644 index 0000000..b2b347c --- /dev/null +++ b/crates/coop/src/panels/import.rs @@ -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 { + cx.new(|cx| ImportPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct ImportPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Secret key input + key_input: Entity, + + /// Password input (if required) + pass_input: Entity, + + /// Error message + error: Entity>, + + /// Countdown timer for nostr connect + countdown: Entity>, + + /// 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 { + 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) { + 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) { + 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) { + 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(&mut self, message: S, cx: &mut Context) + where + S: Into, + { + // 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.logging_in = status; + cx.notify(); + } + + fn set_countdown(&mut self, i: Option, cx: &mut Context) { + 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 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) -> 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()), + ) + }), + ) + } +} diff --git a/crates/coop/src/panels/mod.rs b/crates/coop/src/panels/mod.rs index 44969c6..5237dc2 100644 --- a/crates/coop/src/panels/mod.rs +++ b/crates/coop/src/panels/mod.rs @@ -1 +1,3 @@ +pub mod connect; pub mod greeter; +pub mod import; diff --git a/crates/coop/src/views/startup.rs b/crates/coop/src/views/startup.rs deleted file mode 100644 index b89d9ba..0000000 --- a/crates/coop/src/views/startup.rs +++ /dev/null @@ -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 { - 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, - - /// 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 { - 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.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, - ) { - 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) { - 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.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 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) -> 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); - }, - )), - ) - } -} diff --git a/crates/coop/src/views/welcome.rs b/crates/coop/src/views/welcome.rs deleted file mode 100644 index f2acc1f..0000000 --- a/crates/coop/src/views/welcome.rs +++ /dev/null @@ -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 { - 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 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) -> 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"); - }), - ), - ), - ) - } -} diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index a2fd387..484ce6f 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -4,6 +4,7 @@ use chat::{ChatEvent, ChatRegistry}; use chat_ui::{CopyPublicKey, OpenPublicKey}; use common::DEFAULT_SIDEBAR_WIDTH; use dock::dock::DockPlacement; +use dock::panel::PanelView; use dock::{ClosePanel, DockArea, DockItem}; use gpui::{ div, px, relative, App, AppContext, Axis, ClipboardItem, Context, Entity, InteractiveElement, @@ -108,6 +109,21 @@ impl Workspace { } } + pub fn add_panel

(panel: P, window: &mut Window, cx: &mut App) + where + P: PanelView, + { + if let Some(root) = window.root::().flatten() { + if let Ok(workspace) = root.read(cx).view().clone().downcast::() { + 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) { let weak_dock = self.dock.downgrade(); @@ -340,89 +356,6 @@ impl Workspace { Some(ids) } - - /* - fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context) -> 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 {