feat: revamp the onboarding process (#205)

* redesign

* restructure

* .

* .

* .

* .

* .
This commit is contained in:
reya
2025-11-17 15:10:14 +07:00
committed by GitHub
parent 67c92cb319
commit 6023063cf4
12 changed files with 859 additions and 796 deletions

View File

@@ -30,13 +30,12 @@ use ui::dock_area::{ClosePanel, DockArea, DockItem};
use ui::modal::ModalButtonProps;
use ui::popover::{Popover, PopoverContent};
use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable};
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings};
use crate::views::compose::compose_button;
use crate::views::{
login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome,
};
use crate::views::{onboarding, preferences, sidebar, startup, user_profile, welcome};
use crate::{login, new_identity};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
cx.new(|cx| ChatSpace::new(window, cx))
@@ -48,7 +47,7 @@ pub fn login(window: &mut Window, cx: &mut App) {
}
pub fn new_account(window: &mut Window, cx: &mut App) {
let panel = new_account::init(window, cx);
let panel = new_identity::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
@@ -496,6 +495,43 @@ impl ChatSpace {
)
})
}
fn titlebar_center(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity().downgrade();
let panel = self.dock.read(cx).items.view();
let title = panel.title(cx);
let id = panel.panel_id(cx);
if id == "Onboarding" {
return div();
};
h_flex()
.flex_1()
.w_full()
.justify_center()
.text_center()
.font_semibold()
.text_sm()
.child(
div().flex_1().child(
Button::new("back")
.icon(IconName::ArrowLeft)
.small()
.ghost_alt()
.rounded()
.on_click(move |_ev, window, cx| {
entity
.update(cx, |this, cx| {
this.set_onboarding_layout(window, cx);
})
.expect("Entity has been released");
}),
),
)
.child(div().flex_1().child(title))
.child(div().flex_1())
}
}
impl Render for ChatSpace {
@@ -505,10 +541,16 @@ impl Render for ChatSpace {
let left = self.titlebar_left(window, cx).into_any_element();
let right = self.titlebar_right(window, cx).into_any_element();
let center = self.titlebar_center(cx).into_any_element();
let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty();
// Update title bar children
self.title_bar.update(cx, |this, _cx| {
this.set_children(vec![left, right]);
if single_panel {
this.set_children(vec![center]);
} else {
this.set_children(vec![left, right]);
}
});
div()

View File

@@ -1,439 +1,428 @@
use std::time::Duration;
use anyhow::anyhow;
use common::BUNKER_TIMEOUT;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
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::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, ContextModal, Disableable, StyledExt};
use crate::actions::CoopAuthUrlHandler;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
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 {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(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: "Login".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(t!("login.bunker_invalid"), 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);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
client.set_signer(signer).await;
})
.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) => {
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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
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();
}
// Update the signer
cx.background_spawn(async move {
client.set_signer(keys).await;
})
.detach();
})
.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()
.child(
div()
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("login.title")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
)
.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(t!("common.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(shared_t!("login.approve_message", i = 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()),
)
}),
),
)
}
}
use std::time::Duration;
use anyhow::anyhow;
use common::BUNKER_TIMEOUT;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
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::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, ContextModal, Disableable, StyledExt};
use crate::actions::CoopAuthUrlHandler;
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(t!("login.bunker_invalid"), 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);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
client.set_signer(signer).await;
})
.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) => {
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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
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();
}
// Update the signer
cx.background_spawn(async move {
client.set_signer(keys).await;
})
.detach();
})
.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(t!("common.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(shared_t!("login.approve_message", i = 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()),
)
}),
),
)
}
}

View File

@@ -11,9 +11,11 @@ use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit};
pub(crate) mod actions;
pub(crate) mod chatspace;
pub(crate) mod views;
mod actions;
mod chatspace;
mod login;
mod new_identity;
mod views;
i18n::init!();

View File

@@ -0,0 +1,217 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::home_dir;
use gpui::{
div, App, AppContext, ClipboardItem, Context, Entity, Flatten, 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 Flatten::flatten(path.await.map_err(|e| e.into())) {
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::CheckCircleFill
} 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::CheckCircleFill
} 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)),
)
}
}

View File

@@ -1,9 +1,9 @@
use anyhow::{anyhow, Error};
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
use gpui::{
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, Task, WeakEntity, Window,
rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window,
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
@@ -12,20 +12,20 @@ use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable};
use crate::views::backup_keys::BackupKeys;
mod backup;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
cx.new(|cx| NewAccount::new(window, cx))
}
#[derive(Debug)]
pub struct NewAccount {
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
@@ -38,11 +38,7 @@ pub struct NewAccount {
}
impl NewAccount {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
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));
@@ -53,7 +49,7 @@ impl NewAccount {
temp_keys,
uploading: false,
submitting: false,
name: "New Account".into(),
name: "Create a new identity".into(),
focus_handle: cx.focus_handle(),
}
}
@@ -62,7 +58,7 @@ impl NewAccount {
self.submitting(true, cx);
let keys = self.temp_keys.read(cx).clone();
let view = cx.new(|cx| BackupKeys::new(&keys, window, cx));
let view = backup::init(&keys, window, cx);
let weak_view = view.downgrade();
let current_view = cx.entity().downgrade();
@@ -80,20 +76,25 @@ impl NewAccount {
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let current_view = current_view.clone();
let view = current_view.clone();
let task = this.backup(window, cx);
if let Some(task) = this.backup(window, cx) {
cx.spawn_in(window, async move |_, cx| {
task.await;
cx.spawn_in(window, async move |_this, cx| {
let result = task.await;
current_view
.update(cx, |this, cx| {
this.set_signer(cx);
match result {
Ok(_) => {
view.update_in(cx, |this, window, cx| {
this.set_signer(window, cx);
})
.ok();
})
.detach();
}
.expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to backup: {e}");
}
}
})
.detach();
})
.ok();
// true to close the modal
@@ -102,7 +103,7 @@ impl NewAccount {
})
}
pub fn set_signer(&mut self, cx: &mut Context<Self>) {
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);
@@ -120,7 +121,70 @@ impl NewAccount {
metadata = metadata.picture(url);
};
cx.spawn(async move |_, cx| {
// 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
@@ -129,68 +193,13 @@ impl NewAccount {
.await
.ok();
// Update the signer
// Set the client's signer with the current keys
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Set the client's signer with the current keys
client.set_signer(keys).await;
// Verify the signer
let signer = client.signer().await?;
let nip65_relays = default_nip65_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?;
// Ensure relays are connected
for (url, _metadata) in nip65_relays.iter() {
client.add_relay(url).await?;
client.connect_relay(url).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
}
})
.take(3)
.collect();
// Construct a NIP-17 event
let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(default_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?;
// Set metadata
client.send_event_to(&write_relays, &event).await?;
Ok(())
});
task.detach();
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();
}
@@ -230,39 +239,31 @@ impl NewAccount {
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
this.update_in(cx, |this, window, cx| {
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
})
.ok();
}
Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string());
}
Err(e) => {
Self::notify_error(cx, this, e.to_string());
}
}
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
this.uploading(false, cx);
}
};
})
.expect("Entity has been released");
})
.detach();
}
fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity<NewAccount>, e: String) {
cx.update(|window, cx| {
entity
.update(cx, |this, cx| {
window.push_notification(e, cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.submitting = status;
cx.notify();
@@ -294,64 +295,48 @@ impl Focusable for NewAccount {
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()
.gap_10()
.child(
div()
.text_lg()
.text_center()
.font_semibold()
.line_height(relative(1.3))
.child(shared_t!("new_account.title")),
)
.child(
v_flex()
.w_96()
.gap_4()
.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::PlusCircleFill)
.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(shared_t!("new_account.name"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.child(div().text_sm().child(shared_t!("new_account.avatar")))
.child(
v_flex()
.p_1()
.h_32()
.w_full()
.items_center()
.justify_center()
.gap_2()
.rounded(cx.theme().radius)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.child(
Avatar::new(self.avatar_input.read(cx).value().to_string())
.size(rems(2.25)),
)
.child(
Button::new("upload")
.icon(IconName::Plus)
.label(t!("common.upload"))
.ghost()
.small()
.rounded()
.disabled(self.submitting || self.uploading)
.loading(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
TextInput::new(&self.name_input)
.disabled(self.submitting)
.small(),
),
)
.child(divider(cx))

View File

@@ -1,178 +0,0 @@
use std::fs;
use std::time::Duration;
use common::home_dir;
use gpui::{
div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render,
SharedString, Styled, Task, Window,
};
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable};
pub struct BackupKeys {
pubkey_input: Entity<InputState>,
secret_input: Entity<InputState>,
error: Option<SharedString>,
copied: bool,
}
impl BackupKeys {
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,
}
}
pub fn backup(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<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();
Some(cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(path.await.map_err(|e| e.into())) {
Ok(Ok(Some(path))) => {
cx.update(|window, cx| {
if let Err(e) = fs::write(&path, nsec) {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), window, cx);
})
.ok();
}
})
.ok();
}
_ => {
log::error!("Failed to save backup keys");
}
};
}))
}
fn copy_secret(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string());
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 {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
})
.detach();
}
}
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
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.error = None;
cx.notify();
})
.ok();
})
.ok();
})
.detach();
}
}
impl Render for BackupKeys {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_description")),
)
.child(
v_flex()
.gap_1()
.child(shared_t!("common.pubkey"))
.child(TextInput::new(&self.pubkey_input).small())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_pubkey_note")),
),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(shared_t!("common.secret"))
.child(
h_flex()
.gap_1()
.child(TextInput::new(&self.secret_input).small())
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_secret(window, cx);
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(shared_t!("new_account.backup_secret_note")),
),
)
}
}

View File

@@ -1,8 +1,5 @@
pub mod backup_keys;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod screening;

View File

@@ -18,7 +18,7 @@ use list_item::RoomListItem;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -535,7 +535,9 @@ impl Sidebar {
let client = nostr.read(cx).client();
let task: Task<Result<Vec<Relay>, Error>> = cx.background_spawn(async move {
let subscription = client.subscription(&SubscriptionId::new("inbox")).await;
let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION);
let subscription = client.subscription(&id).await;
let mut relays: Vec<Relay> = vec![];
for (url, _filter) in subscription.into_iter() {
@@ -812,6 +814,7 @@ impl Render for Sidebar {
this.child(deferred(
v_flex()
.py_2()
.px_1p5()
.gap_1p5()
.items_center()
.justify_center()
@@ -835,6 +838,7 @@ impl Render for Sidebar {
this.child(deferred(
v_flex()
.py_2()
.px_1p5()
.gap_1p5()
.items_center()
.justify_center()

View File

@@ -61,7 +61,7 @@ impl Startup {
Self {
credential,
loading: false,
name: "Account".into(),
name: "Continue".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
_subscriptions: subscriptions,

View File

@@ -78,7 +78,7 @@ impl Gossip {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
Some(url.to_owned())
} else {
None
@@ -97,7 +97,7 @@ impl Gossip {
relays
.iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == &Some(RelayMetadata::Read) {
if metadata.is_none() || metadata == &Some(RelayMetadata::Write) {
Some(url.to_owned())
} else {
None

View File

@@ -91,7 +91,11 @@ impl Render for TitleBar {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform_kind.is_mac() {
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING)).pr_2()
this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
.pr_2()
.when(children.len() <= 1, |this| {
this.pr(px(platforms::mac::TRAFFIC_LIGHT_PADDING))
})
} else {
this.px_2()
}

View File

@@ -522,7 +522,7 @@ impl InputState {
if new_row >= last_layout.visible_range.start {
let visible_row = new_row.saturating_sub(last_layout.visible_range.start);
if let Some(line) = last_layout.lines.get(visible_row) {
if let Ok(x) = line.index_for_position(
if let Ok(x) = line.closest_index_for_position(
Point {
x: preferred_x,
y: px(0.),
@@ -1655,10 +1655,11 @@ impl InputState {
// Return offset by use closest_index_for_x if is single line mode.
if self.mode.is_single_line() {
return rendered_line.unwrapped_layout.index_for_x(pos.x).unwrap();
return rendered_line.unwrapped_layout.closest_index_for_x(pos.x);
}
let index_result = rendered_line.index_for_position(pos, line_height);
let index_result = rendered_line.closest_index_for_position(pos, line_height);
if let Ok(v) = index_result {
index += v;
break;