From 0347e8b3c582c2cfcf9d1408c2a1c694fa3f8030 Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 10 Feb 2025 13:46:51 +0700 Subject: [PATCH] feat: support nip46 --- Cargo.lock | 34 +++ crates/app/src/views/onboarding.rs | 334 +++++++++++++++++++++-------- crates/app_state/src/registry.rs | 67 +++--- crates/common/Cargo.toml | 2 + crates/common/src/lib.rs | 1 + crates/common/src/qr.rs | 13 ++ crates/ui/src/input/input.rs | 28 ++- 7 files changed, 352 insertions(+), 127 deletions(-) create mode 100644 crates/common/src/qr.rs diff --git a/Cargo.lock b/Cargo.lock index db55e06..27669ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,9 +1064,11 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "dirs 5.0.1", "gpui", "itertools 0.13.0", "nostr-sdk", + "qrcode-generator", "random_name_generator", ] @@ -2324,6 +2326,15 @@ dependencies = [ "digest", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "1.2.0" @@ -4034,6 +4045,23 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcode-generator" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0051849b5465059b75f59d388c7318aad6554701b74ecf02afc2573b0306c" +dependencies = [ + "html-escape", + "image", + "qrcodegen", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + [[package]] name = "quick-error" version = "2.0.1" @@ -5895,6 +5923,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/crates/app/src/views/onboarding.rs b/crates/app/src/views/onboarding.rs index 5a57b2b..5447356 100644 --- a/crates/app/src/views/onboarding.rs +++ b/crates/app/src/views/onboarding.rs @@ -1,15 +1,15 @@ use app_state::registry::AppRegistry; -use common::profile::NostrProfile; +use common::{profile::NostrProfile, qr::create_qr}; use gpui::{ - div, prelude::FluentBuilder, relative, svg, App, AppContext, BorrowAppContext, Context, Entity, - IntoElement, ParentElement, Render, Styled, Window, + div, img, prelude::FluentBuilder, relative, svg, App, AppContext, BorrowAppContext, + ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render, Styled, Window, }; use nostr_connect::prelude::*; use state::get_client; -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use tokio::sync::oneshot; use ui::{ - button::{Button, ButtonVariants}, + button::{Button, ButtonCustomVariant, ButtonVariants}, input::{InputEvent, TextInput}, notification::NotificationType, theme::{scale::ColorScaleStep, ActiveTheme}, @@ -18,7 +18,7 @@ use ui::{ use super::app; -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 ALPHA_MESSAGE: &str = "Coop is in the alpha stage; it doesn't store any credentials. You will need to log in again when you relanch."; const JOIN_URL: &str = "https://start.njump.me/"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -26,7 +26,10 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { } pub struct Onboarding { - input: Entity, + app_keys: Keys, + connect_uri: NostrConnectURI, + qr_path: Option, + nsec_input: Entity, use_connect: bool, use_privkey: bool, is_loading: bool, @@ -34,26 +37,41 @@ pub struct Onboarding { impl Onboarding { pub fn new(window: &mut Window, cx: &mut App) -> Entity { - let input = cx.new(|cx| { + let app_keys = Keys::generate(); + + let connect_uri = NostrConnectURI::client( + app_keys.public_key(), + vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()], + "Coop", + ); + + let nsec_input = cx.new(|cx| { TextInput::new(window, cx) .text_size(Size::XSmall) .placeholder("nsec...") }); + // Save Connect URI as PNG file for display as QR Code + let qr_path = create_qr(connect_uri.to_string().as_str()).ok(); + cx.new(|cx| { + // Handle Enter event for nsec input cx.subscribe_in( - &input, + &nsec_input, window, move |this: &mut Self, _, input_event, window, cx| { if let InputEvent::PressEnter = input_event { - this.login(window, cx); + this.privkey_login(window, cx); } }, ) .detach(); Self { - input, + app_keys, + connect_uri, + qr_path, + nsec_input, use_connect: false, use_privkey: false, is_loading: false, @@ -61,12 +79,49 @@ impl Onboarding { }) } - /* - fn use_connect(&mut self, _window: &mut Window, cx: &mut Context) { + fn use_connect(&mut self, window: &mut Window, cx: &mut Context) { + let uri = self.connect_uri.clone(); + let app_keys = self.app_keys.clone(); + let window_handle = window.window_handle(); + self.use_connect = true; cx.notify(); + + cx.spawn(|_, mut cx| async move { + let (tx, rx) = oneshot::channel::(); + + cx.background_spawn(async move { + if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) + { + if let Ok(uri) = signer.bunker_uri().await { + let client = get_client(); + + if let Some(public_key) = uri.remote_signer_public_key() { + let metadata = client + .fetch_metadata(*public_key, Duration::from_secs(2)) + .await + .ok() + .unwrap_or_default(); + + _ = client.set_signer(signer).await; + _ = tx.send(NostrProfile::new(*public_key, metadata)); + } + } + } + }) + .detach(); + + if let Ok(profile) = rx.await { + _ = cx.update_window(window_handle, |_, window, cx| { + cx.update_global::(|this, cx| { + this.set_user(Some(profile.clone())); + this.set_root_view(app::init(profile, window, cx).into(), cx); + }); + }) + } + }) + .detach(); } - */ fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context) { self.use_privkey = true; @@ -84,60 +139,212 @@ impl Onboarding { cx.notify(); } - fn login(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).text().to_string(); + fn privkey_login(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.nsec_input.read(cx).text().to_string(); + let window_handle = window.window_handle(); 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 keys = if let Ok(keys) = Keys::parse(&value) { keys } else { - // TODO: handle error + window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx); return; }; + // Show loading spinner + self.set_loading(true, cx); + cx.spawn(|_, mut cx| async move { let client = get_client(); let (tx, rx) = oneshot::channel::(); - cx.background_executor() - .spawn(async move { - let public_key = keys.get_public_key().await.unwrap(); + cx.background_spawn(async move { + if let Ok(public_key) = keys.get_public_key().await { let metadata = client - .fetch_metadata(public_key, Duration::from_secs(3)) + .fetch_metadata(public_key, Duration::from_secs(2)) .await .ok() - .unwrap_or(Metadata::new()); - let profile = NostrProfile::new(public_key, metadata); + .unwrap_or_default(); - _ = tx.send(profile); _ = client.set_signer(keys).await; - }) - .detach(); + _ = tx.send(NostrProfile::new(public_key, metadata)); + } + }) + .detach(); if let Ok(profile) = rx.await { - cx.update_window(window_handle, |_, window, cx| { + _ = cx.update_window(window_handle, |_, window, cx| { cx.update_global::(|this, cx| { this.set_user(Some(profile.clone())); this.set_root_view(app::init(profile, window, cx).into(), cx); }); }) - .unwrap(); } }) .detach(); } + + fn render_selection(&self, window: &mut Window, cx: &mut Context) -> Div { + div() + .w_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .child( + Button::new("login_connect_btn") + .label("Login with Nostr Connect") + .primary() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.use_connect(window, cx); + })), + ) + .child( + Button::new("login_privkey_btn") + .label("Login with Private Key") + .custom( + ButtonCustomVariant::new(window, cx) + .color(cx.theme().base.step(cx, ColorScaleStep::THREE)) + .border(cx.theme().base.step(cx, ColorScaleStep::THREE)) + .hover(cx.theme().base.step(cx, ColorScaleStep::FOUR)) + .active(cx.theme().base.step(cx, ColorScaleStep::FIVE)) + .foreground(cx.theme().base.step(cx, ColorScaleStep::TWELVE)), + ) + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.use_privkey(window, cx); + })), + ) + .child( + div() + .my_2() + .h_px() + .rounded_md() + .w_full() + .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child( + Button::new("join_btn") + .label("Are you new? Join here!") + .ghost() + .w_full() + .on_click(|_, _, cx| { + cx.open_url(JOIN_URL); + }), + ) + } + + fn render_connect_login(&self, cx: &mut Context) -> Div { + let connect_string = self.connect_uri.to_string(); + + div() + .w_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .flex() + .flex_col() + .text_xs() + .text_center() + .child( + div() + .font_semibold() + .line_height(relative(1.2)) + .child("Scan this QR Code in the Nostr Signer app"), + ) + .child("Recommend: Amber (Android), nsec.app (web),..."), + ) + .when_some(self.qr_path.clone(), |this, path| { + this.child( + div() + .mb_2() + .p_2() + .size_72() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .rounded_lg() + .shadow_lg() + .when(cx.theme().appearance.is_dark(), |this| { + this.shadow_none() + .border_1() + .border_color(cx.theme().base.step(cx, ColorScaleStep::SIX)) + }) + .bg(cx.theme().background) + .child(img(path).h_64()), + ) + }) + .child( + Button::new("copy") + .label("Copy Connection String") + .primary() + .w_full() + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(connect_string.clone())) + }), + ) + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.reset(window, cx); + })), + ) + } + + fn render_privkey_login(&self, cx: &mut Context) -> Div { + div() + .w_full() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .flex_col() + .gap_1() + .text_xs() + .child("Private Key:") + .child(self.nsec_input.clone()), + ) + .child( + Button::new("login") + .label("Login") + .primary() + .w_full() + .loading(self.is_loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.privkey_login(window, cx); + })), + ) + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.reset(window, cx); + })), + ) + } } 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() @@ -149,7 +356,7 @@ impl Render for Onboarding { .flex() .flex_col() .items_center() - .gap_6() + .gap_8() .child( div() .flex() @@ -182,62 +389,13 @@ impl Render for Onboarding { ), ), ) - .child(div().w_72().map(|this| { + .child(div().w_72().map(|_| { 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); - })), - ) + self.render_privkey_login(cx) + } else if self.use_connect { + self.render_connect_login(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); - }), - ) + self.render_selection(window, cx) } })), ) diff --git a/crates/app_state/src/registry.rs b/crates/app_state/src/registry.rs index 42beb6e..0c8d995 100644 --- a/crates/app_state/src/registry.rs +++ b/crates/app_state/src/registry.rs @@ -2,7 +2,7 @@ use common::{ constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID}, profile::NostrProfile, }; -use gpui::{AnyView, App, Global, WeakEntity}; +use gpui::{AnyView, App, AppContext, Global, WeakEntity}; use nostr_sdk::prelude::*; use state::get_client; use std::time::Duration; @@ -22,45 +22,44 @@ impl AppRegistry { let client = get_client(); let public_key = profile.public_key(); - cx.background_executor() - .spawn(async move { - let subscription = Filter::new() - .kind(Kind::ContactList) - .author(public_key) - .limit(1); + cx.background_spawn(async move { + let subscription = Filter::new() + .kind(Kind::ContactList) + .author(public_key) + .limit(1); - // Get contact list - _ = client.sync(subscription, &SyncOptions::default()).await; + // Get contact list + _ = client.sync(subscription, &SyncOptions::default()).await; - let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - // Create a filter for getting all gift wrapped events send to current user - let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + // Create a filter for getting all gift wrapped events send to current user + let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - // Create a filter for getting new message - let new_message = Filter::new() - .kind(Kind::GiftWrap) - .pubkey(public_key) - .limit(0); + // Create a filter for getting new message + let new_message = Filter::new() + .kind(Kind::GiftWrap) + .pubkey(public_key) + .limit(0); - // Subscribe for all messages - _ = client - .subscribe_with_id( - all_messages_sub_id, - all_messages, - Some(SubscribeAutoCloseOptions::default().exit_policy( - ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(5)), - )), - ) - .await; + // Subscribe for all messages + _ = client + .subscribe_with_id( + all_messages_sub_id, + all_messages, + Some(SubscribeAutoCloseOptions::default().exit_policy( + ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(5)), + )), + ) + .await; - // Subscribe for new message - _ = client - .subscribe_with_id(new_message_sub_id, new_message, None) - .await; - }) - .detach(); + // Subscribe for new message + _ = client + .subscribe_with_id(new_message_sub_id, new_message, None) + .await; + }) + .detach(); } }) .detach(); diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 027ef19..82592b7 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -10,5 +10,7 @@ nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true chrono.workspace = true +dirs.workspace = true random_name_generator = "0.3.6" +qrcode-generator = "5.0.0" diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 4a6582b..fa33fb5 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,3 +1,4 @@ pub mod constants; pub mod profile; +pub mod qr; pub mod utils; diff --git a/crates/common/src/qr.rs b/crates/common/src/qr.rs new file mode 100644 index 0000000..75ba393 --- /dev/null +++ b/crates/common/src/qr.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +use dirs::config_dir; +use qrcode_generator::QrCodeEcc; + +pub fn create_qr(data: &str) -> Result { + let config_dir = config_dir().expect("Config directory not found"); + let path = config_dir.join("Coop/nostr_connect.png"); + + qrcode_generator::to_png_to_file(data, QrCodeEcc::Low, 1024, &path)?; + + Ok(path) +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 3d50252..0539f4f 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1504,14 +1504,20 @@ impl EntityInputHandler for TextInput { let mut index_offset = 0; for line in lines.iter() { - if let Some(p) = line.position_for_index(range.start - index_offset, line_height) { + if let Some(p) = + line.position_for_index(range.start.saturating_sub(index_offset), line_height) + { start_origin = Some(p + point(px(0.), y_offset)); } - if let Some(p) = line.position_for_index(range.end - index_offset, line_height) { + + if let Some(p) = + line.position_for_index(range.end.saturating_sub(index_offset), line_height) + { end_origin = Some(p + point(px(0.), y_offset)); } y_offset += line.size(line_height).height; + if start_origin.is_some() && end_origin.is_some() { break; } @@ -1519,18 +1525,30 @@ impl EntityInputHandler for TextInput { index_offset += line.len(); } + let start_origin = start_origin.unwrap_or_default(); + let end_origin = end_origin.unwrap_or_default(); + Some(Bounds::from_corners( - bounds.origin + start_origin.unwrap_or_default(), - bounds.origin + end_origin.unwrap_or_default(), + bounds.origin + start_origin, + // + line_height for show IME panel under the cursor line. + bounds.origin + point(end_origin.x, end_origin.y + line_height), )) } fn character_index_for_point( &mut self, - _point: gpui::Point, + point: gpui::Point, _window: &mut Window, _cx: &mut Context, ) -> Option { + let line_height = self.last_line_height; + let line_point = self.last_bounds?.localize(&point)?; + let lines = self.last_layout.as_ref()?; + for line in lines.iter() { + if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { + return Some(self.offset_to_utf16(utf8_index)); + } + } None } }