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::modal::ModalButtonProps;
use ui::popover::{Popover, PopoverContent}; use ui::popover::{Popover, PopoverContent};
use ui::popup_menu::PopupMenuExt; 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::actions::{reset, DarkMode, KeyringPopup, Logout, Settings};
use crate::views::compose::compose_button; use crate::views::compose::compose_button;
use crate::views::{ use crate::views::{onboarding, preferences, sidebar, startup, user_profile, welcome};
login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome, use crate::{login, new_identity};
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
cx.new(|cx| ChatSpace::new(window, cx)) 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) { 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); 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 { impl Render for ChatSpace {
@@ -505,10 +541,16 @@ impl Render for ChatSpace {
let left = self.titlebar_left(window, cx).into_any_element(); let left = self.titlebar_left(window, cx).into_any_element();
let right = self.titlebar_right(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 // Update title bar children
self.title_bar.update(cx, |this, _cx| { self.title_bar.update(cx, |this, _cx| {
if single_panel {
this.set_children(vec![center]);
} else {
this.set_children(vec![left, right]); this.set_children(vec![left, right]);
}
}); });
div() div()

View File

@@ -22,9 +22,10 @@ use ui::{v_flex, ContextModal, Disableable, StyledExt};
use crate::actions::CoopAuthUrlHandler; use crate::actions::CoopAuthUrlHandler;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx) cx.new(|cx| Login::new(window, cx))
} }
#[derive(Debug)]
pub struct Login { pub struct Login {
key_input: Entity<InputState>, key_input: Entity<InputState>,
pass_input: Entity<InputState>, pass_input: Entity<InputState>,
@@ -42,11 +43,7 @@ pub struct Login {
} }
impl Login { impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { fn new(window: &mut Window, cx: &mut Context<Self>) -> 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 key_input = cx.new(|cx| InputState::new(window, cx));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
@@ -78,7 +75,7 @@ impl Login {
pass_input, pass_input,
error, error,
countdown, countdown,
name: "Login".into(), name: "Welcome Back".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
logging_in: false, logging_in: false,
require_password: false, require_password: false,
@@ -370,18 +367,10 @@ impl Render for Login {
.child( .child(
div() div()
.text_center() .text_center()
.child(
div()
.text_xl() .text_xl()
.font_semibold() .font_semibold()
.line_height(relative(1.3)) .line_height(relative(1.3))
.child(shared_t!("login.title")), .child(SharedString::from("Continue with Private Key or Bunker")),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("login.key_description")),
),
) )
.child( .child(
v_flex() v_flex()

View File

@@ -11,9 +11,11 @@ use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit}; use crate::actions::{load_embedded_fonts, quit, Quit};
pub(crate) mod actions; mod actions;
pub(crate) mod chatspace; mod chatspace;
pub(crate) mod views; mod login;
mod new_identity;
mod views;
i18n::init!(); 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 anyhow::{anyhow, Error};
use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS}; use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS};
use gpui::{ use gpui::{
div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Render, SharedString, Styled, Task, WeakEntity, Window, Window,
}; };
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
use i18n::{shared_t, t}; use i18n::{shared_t, t};
@@ -12,20 +12,20 @@ use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use smol::fs; use smol::fs;
use state::NostrRegistry; use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput}; use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps; 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> { 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 { pub struct NewAccount {
name_input: Entity<InputState>, name_input: Entity<InputState>,
avatar_input: Entity<InputState>, avatar_input: Entity<InputState>,
@@ -38,11 +38,7 @@ pub struct NewAccount {
} }
impl NewAccount { impl NewAccount {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let temp_keys = cx.new(|_| Keys::generate()); let temp_keys = cx.new(|_| Keys::generate());
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx)); let avatar_input = cx.new(|cx| InputState::new(window, cx));
@@ -53,7 +49,7 @@ impl NewAccount {
temp_keys, temp_keys,
uploading: false, uploading: false,
submitting: false, submitting: false,
name: "New Account".into(), name: "Create a new identity".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
} }
@@ -62,7 +58,7 @@ impl NewAccount {
self.submitting(true, cx); self.submitting(true, cx);
let keys = self.temp_keys.read(cx).clone(); 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 weak_view = view.downgrade();
let current_view = cx.entity().downgrade(); let current_view = cx.entity().downgrade();
@@ -80,20 +76,25 @@ impl NewAccount {
.on_ok(move |_, window, cx| { .on_ok(move |_, window, cx| {
weak_view weak_view
.update(cx, |this, cx| { .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 |_this, cx| {
cx.spawn_in(window, async move |_, cx| { let result = task.await;
task.await;
current_view match result {
.update(cx, |this, cx| { Ok(_) => {
this.set_signer(cx); view.update_in(cx, |this, window, cx| {
this.set_signer(window, cx);
}) })
.ok(); .expect("Entity has been released");
}
Err(e) => {
log::error!("Failed to backup: {e}");
}
}
}) })
.detach(); .detach();
}
}) })
.ok(); .ok();
// true to close the modal // 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 keystore = KeyStore::global(cx).read(cx).backend();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -120,24 +121,14 @@ impl NewAccount {
metadata = metadata.picture(url); metadata = metadata.picture(url);
}; };
cx.spawn(async move |_, cx| { // Close all modals if available
let url = KeyItem::User.to_string(); window.close_all_modals(cx);
// Write the app keys for further connection
keystore
.write_credentials(&url, &username, &secret, cx)
.await
.ok();
// Update the signer
// Set the client's signer with the current keys // Set the client's signer with the current keys
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Set the client's signer with the current keys let signer = keys.clone();
client.set_signer(keys).await;
// Verify the signer
let signer = client.signer().await?;
let nip65_relays = default_nip65_relays(); let nip65_relays = default_nip65_relays();
let nip17_relays = default_nip17_relays();
// Construct a NIP-65 event // Construct a NIP-65 event
let event = EventBuilder::new(Kind::RelayList, "") let event = EventBuilder::new(Kind::RelayList, "")
@@ -153,12 +144,6 @@ impl NewAccount {
// Set NIP-65 relays // Set NIP-65 relays
client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; 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 // Extract only write relays
let write_relays: Vec<RelayUrl> = nip65_relays let write_relays: Vec<RelayUrl> = nip65_relays
.iter() .iter()
@@ -169,12 +154,17 @@ impl NewAccount {
None None
} }
}) })
.take(3)
.collect(); .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 // Construct a NIP-17 event
let event = EventBuilder::new(Kind::InboxRelays, "") let event = EventBuilder::new(Kind::InboxRelays, "")
.tags(default_nip17_relays().iter().cloned().map(Tag::relay)) .tags(nip17_relays.iter().cloned().map(Tag::relay))
.sign(&signer) .sign(&signer)
.await?; .await?;
@@ -184,13 +174,32 @@ impl NewAccount {
// Construct a metadata event // Construct a metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?; let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Set metadata // Send metadata event to both write relays and bootstrap relays
client.send_event_to(&write_relays, &event).await?; 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(()) Ok(())
}); });
task.detach(); 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(); .detach();
} }
@@ -230,39 +239,31 @@ impl NewAccount {
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) { let result = Flatten::flatten(task.await.map_err(|e| e.into()));
Ok(Ok(url)) => {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.uploading(false, cx); this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| { this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx); this.set_value(url.to_string(), window, cx);
}); });
})
.ok();
} }
Ok(Err(e)) => { Ok(Err(e)) => {
Self::notify_error(cx, this, e.to_string()); window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
} }
Err(e) => { Err(e) => {
Self::notify_error(cx, this, e.to_string()); log::warn!("Failed to upload avatar: {e}");
} this.uploading(false, cx);
} }
};
})
.expect("Entity has been released");
}) })
.detach(); .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>) { fn submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.submitting = status; self.submitting = status;
cx.notify(); cx.notify();
@@ -294,64 +295,48 @@ impl Focusable for NewAccount {
impl Render for NewAccount { impl Render for NewAccount {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = self.avatar_input.read(cx).value();
v_flex() v_flex()
.size_full() .size_full()
.relative() .relative()
.items_center() .items_center()
.justify_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( .child(
v_flex() v_flex()
.w_96() .w_96()
.gap_2()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4() .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( .child(
v_flex() v_flex()
.gap_1() .gap_1()
.text_sm() .text_sm()
.child(shared_t!("new_account.name")) .child(shared_t!("new_account.name"))
.child(TextInput::new(&self.name_input).small()),
)
.child( .child(
v_flex() TextInput::new(&self.name_input)
.gap_1() .disabled(self.submitting)
.child(div().text_sm().child(shared_t!("new_account.avatar"))) .small(),
.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);
})),
),
), ),
) )
.child(divider(cx)) .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 compose;
pub mod edit_profile; pub mod edit_profile;
pub mod login;
pub mod new_account;
pub mod onboarding; pub mod onboarding;
pub mod preferences; pub mod preferences;
pub mod screening; pub mod screening;

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,11 @@ impl Render for TitleBar {
if window.is_fullscreen() { if window.is_fullscreen() {
this.px_2() this.px_2()
} else if cx.theme().platform_kind.is_mac() { } 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 { } else {
this.px_2() this.px_2()
} }

View File

@@ -522,7 +522,7 @@ impl InputState {
if new_row >= last_layout.visible_range.start { if new_row >= last_layout.visible_range.start {
let visible_row = new_row.saturating_sub(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 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 { Point {
x: preferred_x, x: preferred_x,
y: px(0.), y: px(0.),
@@ -1655,10 +1655,11 @@ impl InputState {
// Return offset by use closest_index_for_x if is single line mode. // Return offset by use closest_index_for_x if is single line mode.
if self.mode.is_single_line() { 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 { if let Ok(v) = index_result {
index += v; index += v;
break; break;