use common::{profile::NostrProfile, qr::create_qr, utils::preload}; use gpui::{ div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render, Styled, Window, }; use nostr_connect::prelude::*; use state::get_client; use std::{path::PathBuf, time::Duration}; use tokio::sync::oneshot; use ui::{ button::{Button, ButtonCustomVariant, ButtonVariants}, input::{InputEvent, TextInput}, notification::NotificationType, theme::{scale::ColorScaleStep, ActiveTheme}, ContextModal, Root, Size, StyledExt, }; use super::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 relaunch."; 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 { app_keys: Keys, connect_uri: NostrConnectURI, qr_path: Option, nsec_input: Entity, use_connect: bool, use_privkey: bool, is_loading: bool, } impl Onboarding { pub fn new(window: &mut Window, cx: &mut App) -> Entity { 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( &nsec_input, window, move |this: &mut Self, _, input_event, window, cx| { if let InputEvent::PressEnter = input_event { this.privkey_login(window, cx); } }, ) .detach(); Self { app_keys, connect_uri, qr_path, nsec_input, use_connect: false, use_privkey: false, is_loading: false, } }) } 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(); if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() { _ = client.set_signer(signer).await; _ = preload(client, *public_key).await; } } } } }) .detach(); if let Ok(profile) = rx.await { _ = cx.update_window(window_handle, |_, window, cx| { window.replace_root(cx, |window, cx| { Root::new(app::init(profile, window, cx).into(), window, cx) }); }) } }) .detach(); } 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 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; } let keys = if let Ok(keys) = Keys::parse(&value) { keys } else { 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_spawn(async move { if let Ok(public_key) = keys.get_public_key().await { let metadata = client .fetch_metadata(public_key, Duration::from_secs(2)) .await .ok() .unwrap_or_default(); if tx.send(NostrProfile::new(public_key, metadata)).is_ok() { _ = client.set_signer(keys).await; _ = preload(client, public_key).await; } } }) .detach(); if let Ok(profile) = rx.await { _ = cx.update_window(window_handle, |_, window, cx| { window.replace_root(cx, |window, cx| { Root::new(app::init(profile, window, cx).into(), window, cx) }); }) } }) .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 { div() .size_full() .relative() .flex() .items_center() .justify_center() .child( div() .flex() .flex_col() .items_center() .gap_8() .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_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(|_| { if self.use_privkey { self.render_privkey_login(cx) } else if self.use_connect { self.render_connect_login(cx) } else { self.render_selection(window, cx) } })), ) .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), ) } }