diff --git a/Cargo.lock b/Cargo.lock index c26fc55..2bd0bfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,6 +1118,7 @@ dependencies = [ "gpui_tokio", "itertools 0.13.0", "log", + "nostr-connect", "nostr-sdk", "reqwest_client", "rust-embed", @@ -2850,7 +2851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -3215,6 +3216,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "nostr-connect" +version = "0.39.0" +source = "git+https://github.com/rust-nostr/nostr#dda112c89422cda6740fdae404e09a227a0f79ce" +dependencies = [ + "async-utility", + "nostr", + "nostr-relay-pool", + "tokio", + "tracing", +] + [[package]] name = "nostr-database" version = "0.39.0" @@ -6274,7 +6287,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4cbe4a1..aa7dd08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" } +nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "lmdb", "all-nips", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 69e793d..63b2a95 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,6 +20,7 @@ gpui_tokio.workspace = true reqwest_client.workspace = true tokio.workspace = true +nostr-connect.workspace = true nostr-sdk.workspace = true anyhow.workspace = true serde.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 7ee2f39..1fe304f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -19,8 +19,8 @@ use nostr_sdk::prelude::*; use state::{get_client, initialize_client}; use std::{borrow::Cow, collections::HashSet, ops::Deref, str::FromStr, sync::Arc, time::Duration}; use tokio::sync::mpsc; -use ui::Root; -use views::{app::AppView, onboarding::Onboarding, startup::Startup}; +use ui::{theme::Theme, Root}; +use views::{app::AppView, onboarding, startup::Startup}; mod asset; mod views; @@ -254,6 +254,11 @@ fn main() { .open_window(opts, |window, cx| { window.set_window_title(APP_NAME); window.set_app_id(APP_ID); + window + .observe_window_appearance(|window, cx| { + Theme::sync_system_appearance(Some(window), cx); + }) + .detach(); let root = cx.new(|cx| { Root::new(cx.new(|cx| Startup::new(window, cx)).into(), window, cx) @@ -321,10 +326,7 @@ fn main() { cx.update_global::(|this, cx| { if let Some(root) = this.root() { cx.update_entity(&root, |this: &mut Root, cx| { - this.set_view( - cx.new(|cx| Onboarding::new(window, cx)).into(), - cx, - ); + this.set_view(onboarding::init(window, cx).into(), cx); }); } }); diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index 75a99b4..e4af6bc 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -1,7 +1,3 @@ -use super::{ - chat, contacts, onboarding::Onboarding, profile, settings, sidebar::Sidebar, - welcome::WelcomePanel, -}; use app_state::registry::AppRegistry; use chat_state::registry::ChatRegistry; use common::profile::NostrProfile; @@ -20,6 +16,10 @@ use ui::{ Icon, IconName, Root, Sizable, TitleBar, }; +use super::{ + chat, contacts, onboarding, profile, settings, sidebar::Sidebar, welcome::WelcomePanel, +}; + #[derive(Clone, PartialEq, Eq, Deserialize)] pub enum PanelKind { Room(u64), @@ -170,7 +170,7 @@ impl AppView { // Update root view if let Some(root) = this.root() { cx.update_entity(&root, |this: &mut Root, cx| { - this.set_view(cx.new(|cx| Onboarding::new(window, cx)).into(), cx); + this.set_view(onboarding::init(window, cx).into(), cx); }); } }); diff --git a/crates/app/src/views/onboarding/mod.rs b/crates/app/src/views/onboarding/mod.rs index 7db2843..667cc5d 100644 --- a/crates/app/src/views/onboarding/mod.rs +++ b/crates/app/src/views/onboarding/mod.rs @@ -1,121 +1,265 @@ use app_state::registry::AppRegistry; -use common::{constants::KEYRING_SERVICE, profile::NostrProfile}; +use common::profile::NostrProfile; use gpui::{ - div, AppContext, BorrowAppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, - Window, + div, relative, svg, App, AppContext, BorrowAppContext, Context, Entity, IntoElement, + ParentElement, Render, Styled, Window, }; -use nostr_sdk::prelude::*; +use nostr_connect::prelude::*; use state::get_client; +use std::time::Duration; +use tokio::sync::oneshot; use ui::{ + button::{Button, ButtonVariants}, input::{InputEvent, TextInput}, - Root, + notification::NotificationType, + prelude::FluentBuilder, + theme::{scale::ColorScaleStep, ActiveTheme}, + ContextModal, Root, Size, StyledExt, }; use super::app::AppView; +const ALPHA_MESSAGE: &str = "Coop is in the alpha stage; it does not store any credentials. You will need to log in again when you reopen the app."; +const JOIN_URL: &str = "https://start.njump.me/"; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + Onboarding::new(window, cx) +} + pub struct Onboarding { input: Entity, + use_connect: bool, + use_privkey: bool, + is_loading: bool, } impl Onboarding { - pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { + pub fn new(window: &mut Window, cx: &mut App) -> Entity { let input = cx.new(|cx| { - let mut input = TextInput::new(window, cx); - input.set_size(ui::Size::Medium, window, cx); - input + TextInput::new(window, cx) + .text_size(Size::XSmall) + .placeholder("nsec...") }); - cx.subscribe_in( - &input, - window, - move |_, text_input, input_event, window, cx| { - if let InputEvent::PressEnter = input_event { - let content = text_input.read(cx).text().to_string(); - _ = Self::save_keys(&content, window, cx); - } - }, - ) - .detach(); + cx.new(|cx| { + cx.subscribe_in( + &input, + window, + move |this: &mut Self, _, input_event, window, cx| { + if let InputEvent::PressEnter = input_event { + this.login(window, cx); + } + }, + ) + .detach(); - Self { input } + Self { + input, + use_connect: false, + use_privkey: false, + is_loading: false, + } + }) } - fn save_keys( - content: &str, - window: &mut Window, - cx: &mut Context, - ) -> anyhow::Result<(), anyhow::Error> { - let keys = Keys::parse(content)?; - let public_key = keys.public_key(); - let bech32 = public_key.to_bech32()?; - let secret = keys.secret_key().to_secret_hex(); + fn use_connect(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_connect = true; + cx.notify(); + } + + fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_privkey = true; + cx.notify(); + } + + fn reset(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_privkey = false; + self.use_connect = false; + cx.notify(); + } + + fn set_loading(&mut self, status: bool, cx: &mut Context) { + self.is_loading = status; + cx.notify(); + } + + fn login(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).text().to_string(); + + if !value.starts_with("nsec") || value.is_empty() { + window.push_notification((NotificationType::Warning, "Private Key is required"), cx); + return; + } + + // Show loading spinner + self.set_loading(true, cx); + let window_handle = window.window_handle(); - let task = cx.write_credentials(KEYRING_SERVICE, &bech32, secret.as_bytes()); + let keys = if let Ok(keys) = Keys::parse(&value) { + keys + } else { + // TODO: handle error + return; + }; cx.spawn(|_, mut cx| async move { let client = get_client(); + let (tx, rx) = oneshot::channel::(); - if task.await.is_ok() { - let (tx, mut rx) = tokio::sync::mpsc::channel::(1); + cx.background_executor() + .spawn(async move { + let public_key = keys.get_public_key().await.unwrap(); + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(3)) + .await + .ok() + .unwrap_or(Metadata::new()); + let profile = NostrProfile::new(public_key, metadata); - cx.background_executor() - .spawn(async move { - // Update signer - _ = client.set_signer(keys).await; + _ = tx.send(profile); + _ = client.set_signer(keys).await; + }) + .detach(); - // Get metadata - let metadata = if let Ok(Some(metadata)) = - client.database().metadata(public_key).await - { - metadata - } else { - Metadata::new() - }; + if let Ok(profile) = rx.await { + cx.update_window(window_handle, |_, window, cx| { + cx.update_global::(|this, cx| { + this.set_user(Some(profile.clone())); - _ = tx.send(NostrProfile::new(public_key, metadata)).await; - }) - .await; - - while let Some(profile) = rx.recv().await { - cx.update_window(window_handle, |_, window, cx| { - cx.update_global::(|this, cx| { - this.set_user(Some(profile.clone())); - - if let Some(root) = this.root() { - cx.update_entity(&root, |this: &mut Root, cx| { - this.set_view( - cx.new(|cx| AppView::new(profile, window, cx)).into(), - cx, - ); - }); - } - }); - }) - .unwrap(); - } + if let Some(root) = this.root() { + cx.update_entity(&root, |this: &mut Root, cx| { + this.set_view( + cx.new(|cx| AppView::new(profile, window, cx)).into(), + cx, + ); + }); + } + }); + }) + .unwrap(); } }) .detach(); - - Ok(()) } } impl Render for Onboarding { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .size_full() + .relative() .flex() .items_center() .justify_center() .child( div() - .size_1_3() .flex() .flex_col() - .gap_1() - .child(div().child("Private Key").text_sm()) - .child(self.input.clone()), + .items_center() + .gap_6() + .child( + div() + .flex() + .flex_col() + .items_center() + .gap_4() + .child( + svg() + .path("brand/coop.svg") + .size_12() + .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child( + div() + .text_align(gpui::TextAlign::Center) + .child( + div() + .text_lg() + .font_semibold() + .line_height(relative(1.2)) + .child("Welcome to Coop!"), + ) + .child( + div() + .text_sm() + .text_color( + cx.theme().base.step(cx, ColorScaleStep::ELEVEN), + ) + .child("A Nostr client for secure communication."), + ), + ), + ) + .child(div().w_72().map(|this| { + if self.use_privkey { + this.flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .flex_col() + .gap_1() + .text_xs() + .child("Private Key:") + .child(self.input.clone()), + ) + .child( + Button::new("login") + .label("Login") + .primary() + .w_full() + .loading(self.is_loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.login(window, cx); + })), + ) + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.reset(window, cx); + })), + ) + } else { + this.flex() + .flex_col() + .items_center() + .gap_2() + .child( + Button::new("login_btn") + .label("Login with Private Key") + .primary() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.use_privkey(window, cx); + })), + ) + .child( + Button::new("join_btn") + .label("Join Nostr") + .ghost() + .w_full() + .on_click(|_, _, cx| { + cx.open_url(JOIN_URL); + }), + ) + } + })), + ) + .child( + div() + .absolute() + .bottom_2() + .w_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) + .text_align(gpui::TextAlign::Center) + .child(ALPHA_MESSAGE), ) } } diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 8329061..017863a 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,10 +1,12 @@ pub const KEYRING_SERVICE: &str = "Coop Safe Storage"; pub const APP_NAME: &str = "Coop"; pub const APP_ID: &str = "su.reya.coop"; + pub const FAKE_SIG: &str = "f9e79d141c004977192d05a86f81ec7c585179c371f7350a5412d33575a2a356433f58e405c2296ed273e2fe0aafa25b641e39cc4e1f3f261ebf55bce0cbac83"; -pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwrap"; + +/// Subscriptions +pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; -pub const METADATA_DELAY: u64 = 200; /// Image Resizer Service pub const IMAGE_SERVICE: &str = "https://wsrv.nl";