.
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m26s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m22s
Rust / build (macos-latest, stable) (push) Has been cancelled
Rust / build (windows-latest, stable) (push) Has been cancelled
Rust / build (macos-latest, stable) (pull_request) Has been cancelled
Rust / build (windows-latest, stable) (pull_request) Has been cancelled

This commit is contained in:
2026-03-02 06:20:10 +07:00
parent f8e6b3ff7a
commit 703c4923ca
18 changed files with 767 additions and 641 deletions

3
assets/icons/device.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M14.25 10.75C14.25 9.64543 15.1454 8.75 16.25 8.75H20.25C21.3546 8.75 22.25 9.64543 22.25 10.75V19.25C22.25 20.3546 21.3546 21.25 20.25 21.25H16.25C15.1454 21.25 14.25 20.3546 14.25 19.25V10.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 18.25H19.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20.25 8.75V5.75C20.25 4.64543 19.3546 3.75 18.25 3.75H5.75C4.64543 3.75 3.75 4.64543 3.75 5.75V14.75C3.75 15.8546 2.85457 16.75 1.75 16.75V18.25C1.75 19.3546 2.64543 20.25 3.75 20.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 16.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

3
assets/icons/scan.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V9.25M16.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V9.25M21.25 14.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.75M7.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V14.75M7.75 9.75V14.25M16.25 9.75V14.25M12 9.75V12.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@@ -113,7 +113,7 @@ impl ChatRegistry {
subscriptions.push( subscriptions.push(
// Observe the nip65 state and load chat rooms on every state change // Observe the nip65 state and load chat rooms on every state change
cx.observe(&nostr, |this, state, cx| { cx.observe(&nostr, |this, state, cx| {
match state.read(cx).relay_list_state() { match state.read(cx).relay_list_state {
RelayState::Idle => { RelayState::Idle => {
this.reset(cx); this.reset(cx);
} }

View File

@@ -876,7 +876,7 @@ impl ChatPanel {
window.open_modal(cx, move |this, _window, cx| { window.open_modal(cx, move |this, _window, cx| {
this.show_close(true) this.show_close(true)
.title(SharedString::from("Sent Reports")) .title(SharedString::from("Sent Reports"))
.child(v_flex().pb_4().gap_4().children({ .child(v_flex().pb_2().gap_4().children({
let mut items = Vec::with_capacity(reports.len()); let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() { for report in reports.iter() {

View File

@@ -0,0 +1,187 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use state::{NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, WindowExtension};
use crate::dialogs::connect::ConnectSigner;
use crate::dialogs::import::ImportKey;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AccountSelector> {
cx.new(|cx| AccountSelector::new(window, cx))
}
/// Account selector
pub struct AccountSelector {
/// The error message displayed when an error occurs.
error: Entity<Option<SharedString>>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Subscription to the signer events
_subscription: Option<Subscription>,
}
impl AccountSelector {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
// Subscribe to the signer events
let nostr = NostrRegistry::global(cx);
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| {
match event {
SignerEvent::Set => {
window.close_all_modals(cx);
window.refresh();
}
SignerEvent::Error(e) => {
this.error.update(cx, |this, cx| {
*this = Some(e.into());
cx.notify();
});
}
};
});
Self {
error,
tasks: vec![],
_subscription: Some(subscription),
}
}
fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).get_signer(&public_key, cx);
let error = self.error.downgrade();
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
match task.await {
Ok(signer) => {
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
});
}
Err(e) => {
error.update(cx, |this, cx| {
*this = Some(e.to_string().into());
cx.notify();
})?;
}
};
Ok(())
}));
}
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let import = cx.new(|cx| ImportKey::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Import a Secret Key or Bunker Connection")
.show_close(true)
.pb_2()
.child(import.clone())
});
}
fn open_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let connect = cx.new(|cx| ConnectSigner::new(window, cx));
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(460.))
.title("Scan QR Code to Connect")
.show_close(true)
.pb_2()
.child(connect.clone())
});
}
}
impl Render for AccountSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
v_flex()
.size_full()
.gap_2()
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.italic()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.children({
let mut items = vec![];
for (ix, public_key) in npubs.read(cx).iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
items.push(
h_flex()
.id(ix)
.group("")
.px_2()
.h_10()
.gap_2()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child(Avatar::new(profile.avatar()).small())
.child(div().text_sm().child(profile.name()))
.on_click(cx.listener({
let public_key = *public_key;
move |this, _ev, window, cx| {
this.login(public_key, window, cx);
}
})),
);
}
items
})
.child(div().w_full().h_px().bg(cx.theme().border_variant))
.child(
h_flex()
.gap_1()
.justify_end()
.w_full()
.child(
Button::new("input")
.icon(Icon::new(IconName::Usb))
.label("Import")
.ghost()
.small()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_import(window, cx);
})),
)
.child(
Button::new("qr")
.icon(Icon::new(IconName::Scan))
.label("Scan QR to connect")
.ghost()
.small()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_connect(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,115 @@
use std::sync::Arc;
use std::time::Duration;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::{
CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY,
NOSTR_CONNECT_TIMEOUT,
};
use theme::ActiveTheme;
use ui::v_flex;
pub struct ConnectSigner {
/// QR Code
qr_code: Option<Arc<Image>>,
/// Error message
error: Entity<Option<SharedString>>,
/// Subscription to the signer event
_subscription: Option<Subscription>,
}
impl ConnectSigner {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let error = cx.new(|_| None);
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
// Generate the nostr connect uri
let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
// Generate the nostr connect
let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap();
// Handle the auth request
signer.auth_url_handler(CoopAuthUrlHandler);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Subscribe to the signer event
let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
});
Self {
qr_code,
error,
_subscription: Some(subscription),
}
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
self.error.update(cx, |this, cx| {
*this = Some(message.into());
cx.notify();
});
}
}
impl Render for ConnectSigner {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MSG: &str = "Scan with any Nostr Connect-compatible app to connect";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(MSG)),
)
}
}

View File

@@ -0,0 +1,301 @@
use std::time::Duration;
use anyhow::{anyhow, Error};
use gpui::prelude::FluentBuilder;
use gpui::{
div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::{v_flex, Disableable};
#[derive(Debug)]
pub struct ImportKey {
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently loading
loading: bool,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl ImportKey {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
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| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
subscriptions.push(
// Subscribe to the nostr signer event
cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| {
if let SignerEvent::Error(e) = event {
this.set_error(e, cx);
}
}),
);
Self {
key_input,
pass_input,
error,
countdown,
loading: false,
tasks: vec![],
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading {
return;
};
// Prevent duplicate login requests
self.set_loading(true, cx);
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
if value.starts_with("bunker://") {
self.bunker(&value, window, cx);
return;
}
if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
});
} else {
self.set_error("Invalid key", cx);
}
}
fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectUri::parse(content) else {
self.set_error("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys.clone();
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap();
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Set signer in the background
nostr.update(cx, |this, cx| {
this.add_nip46_signer(&signer, cx);
});
// Start countdown
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
for i in (0..=30).rev() {
if i == 0 {
this.update(cx, |this, cx| {
this.set_countdown(None, cx);
})?;
} else {
this.update(cx, |this, cx| {
this.set_countdown(Some(i), cx);
})?;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
}
Ok(())
}));
}
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
{
let nostr = NostrRegistry::global(cx);
let content: String = content.into();
let password: String = pwd.into();
if password.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;
};
// 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"))
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
nostr.update(cx, |this, cx| {
this.add_key_signer(&keys, cx);
});
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
}
Ok(())
}));
}
fn set_error<S>(&mut self, message: S, cx: &mut Context<Self>)
where
S: Into<SharedString>,
{
// Reset the log in state
self.set_loading(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
self.tasks.push(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(())
}));
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = 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 Render for ImportKey {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.p_4()
.gap_2()
.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.key_input.read(cx).value().starts_with("ncryptsec1"),
|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("Continue")
.primary()
.loading(self.loading)
.disabled(self.loading)
.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(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
i
))),
)
})
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger_active)
.child(error.clone()),
)
})
}
}

View File

@@ -1,2 +1,6 @@
pub mod accounts;
pub mod screening; pub mod screening;
pub mod settings; pub mod settings;
mod connect;
mod import;

View File

@@ -254,7 +254,7 @@ impl Screening {
let total = contacts.len(); let total = contacts.len();
this.title(SharedString::from("Mutual contacts")).child( this.title(SharedString::from("Mutual contacts")).child(
v_flex().gap_1().pb_4().child( v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| { uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total); let mut items = Vec::with_capacity(total);
@@ -356,9 +356,9 @@ impl Render for Screening {
.child( .child(
Button::new("report") Button::new("report")
.tooltip("Report as a scam or impostor") .tooltip("Report as a scam or impostor")
.icon(IconName::Boom) .icon(IconName::Warning)
.small() .small()
.danger() .warning()
.rounded() .rounded()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
this.report(window, cx); this.report(window, cx);

View File

@@ -1,127 +0,0 @@
use std::sync::Arc;
use common::TextUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task,
Window,
};
use smallvec::{smallvec, SmallVec};
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
use ui::notification::Notification;
use ui::{v_flex, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ConnectPanel> {
cx.new(|cx| ConnectPanel::new(window, cx))
}
pub struct ConnectPanel {
name: SharedString,
focus_handle: FocusHandle,
/// QR Code
qr_code: Option<Arc<Image>>,
/// Background tasks
_tasks: SmallVec<[Task<()>; 1]>,
}
impl ConnectPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let (signer, uri) = nostr.read(cx).client_connect(None);
// Generate a QR code for quick connection
let qr_code = uri.to_string().to_qr();
let mut tasks = smallvec![];
tasks.push(
// Wait for nostr connect
cx.spawn_in(window, async move |_this, cx| {
let result = signer.bunker_uri().await;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
}),
);
Self {
name: "Nostr Connect".into(),
focus_handle: cx.focus_handle(),
qr_code,
_tasks: tasks,
}
}
}
impl Panel for ConnectPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ConnectPanel {}
impl Focusable for ConnectPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ConnectPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Continue with Nostr Connect")),
)
.child(div().text_sm().text_color(cx.theme().text_muted).child(
SharedString::from("Use Nostr Connect apps to scan the code"),
)),
)
.when_some(self.qr_code.as_ref(), |this, qr| {
this.child(
img(qr.clone())
.size(px(256.))
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().border),
)
})
}
}

View File

@@ -86,7 +86,7 @@ impl Render for GreeterPanel {
let nip17 = chat.read(cx).state(cx); let nip17 = chat.read(cx).state(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).relay_list_state(); let nip65 = nostr.read(cx).relay_list_state.clone();
let required_actions = let required_actions =
nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable; nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable;
@@ -188,48 +188,6 @@ impl Render for GreeterPanel {
), ),
) )
}) })
.child(
v_flex()
.gap_2()
.w_full()
.child(
h_flex()
.gap_2()
.w_full()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Use your own identity"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.gap_2()
.w_full()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
//
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.justify_start()
.on_click(move |_ev, window, cx| {
//
}),
),
),
)
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()

View File

@@ -1,371 +0,0 @@
use std::time::Duration;
use anyhow::anyhow;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use state::{CoopAuthUrlHandler, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::dock_area::ClosePanel;
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{v_flex, Disableable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ImportPanel> {
cx.new(|cx| ImportPanel::new(window, cx))
}
#[derive(Debug)]
pub struct ImportPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
pass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Countdown timer for nostr connect
countdown: Entity<Option<u64>>,
/// Whether the user is currently logging in
logging_in: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl ImportPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).masked(true));
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| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
};
}),
);
Self {
key_input,
pass_input,
error,
countdown,
name: "Import".into(),
focus_handle: cx.focus_handle(),
logging_in: 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);
return;
}
if value.starts_with("ncryptsec1") {
self.login_with_password(&value, &password, window, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), 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("Bunker is not valid", cx);
return;
};
let nostr = NostrRegistry::global(cx);
let weak_state = nostr.downgrade();
let app_keys = nostr.read(cx).app_keys();
let timeout = Duration::from_secs(30);
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..=30).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;
weak_state
.update_in(cx, |this, window, cx| {
match result {
Ok(uri) => {
this.persist_bunker(uri, cx);
this.set_signer(signer, true, cx);
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
};
})
.ok();
})
.detach();
}
pub fn login_with_password(
&mut self,
content: &str,
pwd: &str,
window: &mut Window,
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_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(keys) => {
let nostr = NostrRegistry::global(cx);
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, true, cx);
});
// Close the current panel after setting the signer
window.dispatch_action(Box::new(ClosePanel), cx);
}
Err(e) => {
this.set_error(e.to_string(), cx);
}
};
})
.ok();
})
.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 ImportPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
}
impl EventEmitter<PanelEvent> for ImportPanel {}
impl Focusable for ImportPanel {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ImportPanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const SECRET_WARN: &str = "* Coop doesn't store your secret key. \
It will be cleared when you close the app. \
To persist your identity, please connect via Nostr Connect.";
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()
.text_center()
.font_semibold()
.line_height(relative(1.25))
.child(SharedString::from("Import a Secret Key or Bunker")),
)
.child(
v_flex()
.w_112()
.gap_2()
.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.key_input.read(cx).value().starts_with("ncryptsec1"),
|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("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(SharedString::from(format!(
"Approve connection request from your signer in {} seconds",
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()),
)
})
.child(
div()
.mt_2()
.italic()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(SECRET_WARN)),
),
)
}
}

View File

@@ -23,7 +23,7 @@ use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification; use ui::notification::Notification;
use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension};
use crate::dialogs::settings; use crate::dialogs::{accounts, settings};
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
use crate::sidebar; use crate::sidebar;
@@ -64,11 +64,13 @@ pub struct Workspace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 3]>, _subscriptions: SmallVec<[Subscription; 4]>,
} }
impl Workspace { impl Workspace {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let npubs = nostr.read(cx).npubs();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let titlebar = cx.new(|_| TitleBar::new()); let titlebar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx));
@@ -82,6 +84,24 @@ impl Workspace {
}), }),
); );
subscriptions.push(
// Observe the nostr entity
cx.observe_in(&nostr, window, move |this, nostr, window, cx| {
if nostr.read(cx).connected {
this.set_layout(window, cx);
}
}),
);
subscriptions.push(
// Observe the npubs entity
cx.observe_in(&npubs, window, move |this, npubs, window, cx| {
if !npubs.read(cx).is_empty() {
this.account_selector(window, cx);
}
}),
);
subscriptions.push( subscriptions.push(
// Observe all events emitted by the chat registry // Observe all events emitted by the chat registry
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
@@ -126,11 +146,6 @@ impl Workspace {
}), }),
); );
// Set the default layout for app's dock
cx.defer_in(window, |this, window, cx| {
this.set_layout(window, cx);
});
Self { Self {
titlebar, titlebar,
dock, dock,
@@ -206,7 +221,7 @@ impl Workspace {
window.open_modal(cx, move |this, _window, _cx| { window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.)) this.width(px(520.))
.show_close(true) .show_close(true)
.pb_4() .pb_2()
.title("Preferences") .title("Preferences")
.child(view.clone()) .child(view.clone())
}); });
@@ -341,6 +356,20 @@ impl Workspace {
}); });
} }
fn account_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let accounts = accounts::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(520.))
.title("Continue with")
.show_close(false)
.keyboard(false)
.overlay_closable(false)
.pb_2()
.child(accounts.clone())
});
}
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| { window.open_modal(cx, move |this, _window, cx| {
let registry = ThemeRegistry::global(cx); let registry = ThemeRegistry::global(cx);
@@ -349,20 +378,22 @@ impl Workspace {
this.width(px(520.)) this.width(px(520.))
.show_close(true) .show_close(true)
.title("Select theme") .title("Select theme")
.pb_4() .pb_2()
.child(v_flex().gap_2().w_full().children({ .child(v_flex().gap_2().w_full().children({
let mut items = vec![]; let mut items = vec![];
for (ix, (path, theme)) in themes.iter().enumerate() { for (ix, (path, theme)) in themes.iter().enumerate() {
items.push( items.push(
h_flex() h_flex()
.id(ix)
.group("") .group("")
.px_2() .px_2()
.h_8() .h_8()
.w_full() .w_full()
.justify_between() .justify_between()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .bg(cx.theme().ghost_element_background)
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.child( .child(
h_flex() h_flex()
.gap_1p5() .gap_1p5()
@@ -485,12 +516,12 @@ impl Workspace {
}), }),
) )
}) })
.when(nostr.read(cx).creating(), |this| { .when(nostr.read(cx).creating, |this| {
this.child(div().text_xs().text_color(cx.theme().text_muted).child( this.child(div().text_xs().text_color(cx.theme().text_muted).child(
SharedString::from("Coop is creating a new identity for you..."), SharedString::from("Coop is creating a new identity for you..."),
)) ))
}) })
.when(!nostr.read(cx).connected(), |this| { .when(!nostr.read(cx).connected, |this| {
this.child( this.child(
div() div()
.text_xs() .text_xs()
@@ -503,7 +534,6 @@ impl Workspace {
fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement { fn titlebar_right(&mut self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let relay_list = nostr.read(cx).relay_list_state();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let inbox_state = chat.read(cx).state(cx); let inbox_state = chat.read(cx).state(cx);
@@ -633,7 +663,7 @@ impl Workspace {
div() div()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.map(|this| match relay_list { .map(|this| match nostr.read(cx).relay_list_state {
RelayState::Checking => this RelayState::Checking => this
.child(div().child(SharedString::from( .child(div().child(SharedString::from(
"Fetching user's relay list...", "Fetching user's relay list...",
@@ -652,7 +682,9 @@ impl Workspace {
.tooltip("User's relay list") .tooltip("User's relay list")
.small() .small()
.ghost() .ghost()
.when(relay_list.configured(), |this| this.indicator()) .when(nostr.read(cx).relay_list_state.configured(), |this| {
this.indicator()
})
.dropdown_menu(move |this, _window, cx| { .dropdown_menu(move |this, _window, cx| {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let urls = nostr.read(cx).read_only_relays(&pkey, cx); let urls = nostr.read(cx).read_only_relays(&pkey, cx);

View File

@@ -66,7 +66,7 @@ impl DeviceRegistry {
subscriptions.push( subscriptions.push(
// Observe the NIP-65 state // Observe the NIP-65 state
cx.observe(&nostr, |this, state, cx| { cx.observe(&nostr, |this, state, cx| {
if state.read(cx).relay_list_state() == RelayState::Configured { if state.read(cx).relay_list_state == RelayState::Configured {
this.get_announcement(cx); this.get_announcement(cx);
}; };
}), }),
@@ -477,7 +477,7 @@ impl DeviceRegistry {
let write_relays = nostr.read(cx).write_relays(&public_key, cx); let write_relays = nostr.read(cx).write_relays(&public_key, cx);
let app_keys = nostr.read(cx).app_keys().clone(); let app_keys = nostr.read(cx).app_keys.clone();
let app_pubkey = app_keys.public_key(); let app_pubkey = app_keys.public_key();
let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Keys>, Error>> = cx.background_spawn(async move {
@@ -549,7 +549,7 @@ impl DeviceRegistry {
/// Parse the response event for device keys from other devices /// Parse the response event for device keys from other devices
fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) { fn extract_encryption(&mut self, event: Event, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let app_keys = nostr.read(cx).app_keys().clone(); let app_keys = nostr.read(cx).app_keys.clone();
let task: Task<Result<Keys, Error>> = cx.background_spawn(async move { let task: Task<Result<Keys, Error>> = cx.background_spawn(async move {
let root_device = event let root_device = event

View File

@@ -25,7 +25,7 @@ pub const FIND_LIMIT: usize = 20;
pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default Nostr Connect relay /// Default Nostr Connect relay
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com";
/// Default subscription id for device gift wrap events /// Default subscription id for device gift wrap events
pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps";

View File

@@ -5,7 +5,7 @@ use std::time::Duration;
use anyhow::{anyhow, Context as AnyhowContext, Error}; use anyhow::{anyhow, Context as AnyhowContext, Error};
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window};
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use nostr_lmdb::prelude::*; use nostr_lmdb::prelude::*;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -42,6 +42,16 @@ struct GlobalNostrRegistry(Entity<NostrRegistry>);
impl Global for GlobalNostrRegistry {} impl Global for GlobalNostrRegistry {}
/// Signer event.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SignerEvent {
/// A new signer has been set
Set,
/// An error occurred
Error(String),
}
/// Nostr Registry /// Nostr Registry
#[derive(Debug)] #[derive(Debug)]
pub struct NostrRegistry { pub struct NostrRegistry {
@@ -54,22 +64,22 @@ pub struct NostrRegistry {
/// Local public keys /// Local public keys
npubs: Entity<Vec<PublicKey>>, npubs: Entity<Vec<PublicKey>>,
/// App keys
///
/// Used for Nostr Connect and NIP-4e operations
app_keys: Keys,
/// Custom gossip implementation /// Custom gossip implementation
gossip: Entity<Gossip>, gossip: Entity<Gossip>,
/// App keys
///
/// Used for Nostr Connect and NIP-4e operations
pub app_keys: Keys,
/// Relay list state /// Relay list state
relay_list_state: RelayState, pub relay_list_state: RelayState,
/// Whether Coop is connected to all bootstrap relays /// Whether Coop is connected to all bootstrap relays
connected: bool, pub connected: bool,
/// Whether Coop is creating a new signer /// Whether Coop is creating a new signer
creating: bool, pub creating: bool,
/// Tasks for asynchronous operations /// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -146,29 +156,9 @@ impl NostrRegistry {
self.signer.clone() self.signer.clone()
} }
/// Get the app keys /// Get the npubs entity
pub fn app_keys(&self) -> &Keys { pub fn npubs(&self) -> Entity<Vec<PublicKey>> {
&self.app_keys self.npubs.clone()
}
/// Get the connected status of the client
pub fn connected(&self) -> bool {
self.connected
}
/// Get the creating status
pub fn creating(&self) -> bool {
self.creating
}
/// Get the relay list state
pub fn relay_list_state(&self) -> RelayState {
self.relay_list_state.clone()
}
/// Get all relays for a given public key without ensuring connections
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
self.gossip.read(cx).read_only_relays(public_key)
} }
/// Set the connected status of the client /// Set the connected status of the client
@@ -304,10 +294,11 @@ impl NostrRegistry {
Ok(public_keys) => match public_keys.is_empty() { Ok(public_keys) => match public_keys.is_empty() {
true => { true => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.create_new_signer(cx); this.create_identity(cx);
})?; })?;
} }
false => { false => {
// TODO: auto login
npubs.update(cx, |this, cx| { npubs.update(cx, |this, cx| {
this.extend(public_keys); this.extend(public_keys);
cx.notify(); cx.notify();
@@ -317,10 +308,11 @@ impl NostrRegistry {
Err(e) => { Err(e) => {
log::error!("Failed to get npubs: {e}"); log::error!("Failed to get npubs: {e}");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.create_new_signer(cx); this.create_identity(cx);
})?; })?;
} }
} }
Ok(()) Ok(())
})); }));
} }
@@ -332,7 +324,7 @@ impl NostrRegistry {
} }
/// Create a new identity /// Create a new identity
pub fn create_new_signer(&mut self, cx: &mut Context<Self>) { fn create_identity(&mut self, cx: &mut Context<Self>) {
let client = self.client(); let client = self.client();
let keys = Keys::generate(); let keys = Keys::generate();
let async_keys = keys.clone(); let async_keys = keys.clone();
@@ -411,16 +403,17 @@ impl NostrRegistry {
})); }));
} }
// Get the signer in keyring by username /// Get the signer in keyring by username
pub fn get_signer( pub fn get_signer(
&mut self, &self,
username: &str, public_key: &PublicKey,
cx: &mut Context<Self>, cx: &App,
) -> Task<Result<Arc<dyn NostrSigner>, Error>> { ) -> Task<Result<Arc<dyn NostrSigner>, Error>> {
let app_keys = self.app_keys().clone(); let username = public_key.to_bech32().unwrap();
let read_credential = cx.read_credentials(username); let app_keys = self.app_keys.clone();
let read_credential = cx.read_credentials(&username);
cx.spawn(async move |_this, _cx| { cx.spawn(async move |_cx| {
let (_, secret) = read_credential let (_, secret) = read_credential
.await .await
.map_err(|_| anyhow!("Failed to get signer"))? .map_err(|_| anyhow!("Failed to get signer"))?
@@ -439,7 +432,7 @@ impl NostrRegistry {
let uri = let uri =
NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?;
let timeout = Duration::from_secs(120); let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; let nip46 = NostrConnect::new(uri, app_keys, timeout, None)?;
Ok(nip46.into_nostr_signer()) Ok(nip46.into_nostr_signer())
@@ -478,30 +471,37 @@ impl NostrRegistry {
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// set signer match task.await {
let public_key = task.await?; Ok(public_key) => {
// Update states
this.update(cx, |this, cx| {
// Add public key to npubs if not already present
this.npubs.update(cx, |this, cx| {
if !this.contains(&public_key) {
this.push(public_key);
cx.notify();
}
});
// Update states // Ensure relay list for the user
this.update(cx, |this, cx| { this.ensure_relay_list(cx);
this.npubs.update(cx, |this, cx| {
if !this.contains(&public_key) {
this.push(public_key);
cx.notify();
}
});
this.ensure_relay_list(cx);
})?;
// Emit signer changed event
cx.emit(SignerEvent::Set);
})?;
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
}
Ok(()) Ok(())
})); }));
} }
/// Add a key signer to keyring /// Add a key signer to keyring
pub fn add_key_signer( pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context<Self>) {
&mut self,
keys: &Keys,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let keys = keys.clone(); let keys = keys.clone();
let username = keys.public_key().to_bech32().unwrap(); let username = keys.public_key().to_bech32().unwrap();
let secret = keys.secret_key().to_secret_bytes(); let secret = keys.secret_key().to_secret_bytes();
@@ -509,25 +509,26 @@ impl NostrRegistry {
// Write the credential to the keyring // Write the credential to the keyring
let write_credential = cx.write_credentials(&username, &username, &secret); let write_credential = cx.write_credentials(&username, &username, &secret);
cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match write_credential.await { match write_credential.await {
Ok(_) => { Ok(_) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_signer(keys, cx); this.set_signer(keys, cx);
})?; })?;
} }
Err(e) => return Err(anyhow!("Failed to write credential: {e}")), Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
} }
Ok(()) Ok(())
}) }));
} }
/// Add a nostr connect signer to keyring /// Add a nostr connect signer to keyring
pub fn add_nip46_signer( pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context<Self>) {
&mut self,
nip46: &NostrConnect,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let nip46 = nip46.clone(); let nip46 = nip46.clone();
let async_nip46 = nip46.clone(); let async_nip46 = nip46.clone();
@@ -540,7 +541,7 @@ impl NostrRegistry {
Ok((public_key, uri)) Ok((public_key, uri))
}); });
cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
Ok((public_key, uri)) => { Ok((public_key, uri)) => {
let username = public_key.to_bech32().unwrap(); let username = public_key.to_bech32().unwrap();
@@ -554,13 +555,22 @@ impl NostrRegistry {
this.set_signer(nip46, cx); this.set_signer(nip46, cx);
})?; })?;
} }
Err(e) => return Err(anyhow!("Failed to write credential: {e}")), Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
} }
} }
Err(e) => return Err(anyhow!("Failed to connect to the remote signer: {e}")), Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(SignerEvent::Error(e.to_string()));
})?;
}
} }
Ok(()) Ok(())
}) }));
} }
/// Set the state of the relay list /// Set the state of the relay list
@@ -716,9 +726,14 @@ impl NostrRegistry {
}) })
} }
/// Get all relays for a given public key without ensuring connections
pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec<SharedString> {
self.gossip.read(cx).read_only_relays(public_key)
}
/// Generate a direct nostr connection initiated by the client /// Generate a direct nostr connection initiated by the client
pub fn nostr_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) { pub fn nostr_connect(&self, relay: Option<RelayUrl>) -> (NostrConnect, NostrConnectUri) {
let app_keys = self.app_keys(); let app_keys = self.app_keys.clone();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
// Determine the relay will be used for Nostr Connect // Determine the relay will be used for Nostr Connect
@@ -894,6 +909,8 @@ impl NostrRegistry {
} }
} }
impl EventEmitter<SignerEvent> for NostrRegistry {}
/// Get or create a new app keys /// Get or create a new app keys
fn get_or_init_app_keys() -> Result<Keys, Error> { fn get_or_init_app_keys() -> Result<Keys, Error> {
let dir = config_dir().join(".app_keys"); let dir = config_dir().join(".app_keys");

View File

@@ -34,6 +34,7 @@ pub enum IconName {
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Copy, Copy,
Device,
Door, Door,
Ellipsis, Ellipsis,
Emoji, Emoji,
@@ -52,6 +53,7 @@ pub enum IconName {
Relay, Relay,
Reply, Reply,
Refresh, Refresh,
Scan,
Search, Search,
Settings, Settings,
Settings2, Settings2,
@@ -102,6 +104,7 @@ impl IconNamed for IconName {
Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg", Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg", Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg", Self::Emoji => "icons/emoji.svg",
@@ -120,6 +123,7 @@ impl IconNamed for IconName {
Self::Relay => "icons/relay.svg", Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg", Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg", Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg", Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg", Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg", Self::Settings2 => "icons/settings2.svg",

View File

@@ -343,7 +343,7 @@ impl RenderOnce for Modal {
}); });
let window_paddings = crate::root::window_paddings(window, cx); let window_paddings = crate::root::window_paddings(window, cx);
let radius = (cx.theme().radius_lg * 2.).min(px(20.)); let radius = cx.theme().radius_lg;
let view_size = window.viewport_size() let view_size = window.viewport_size()
- gpui::size( - gpui::size(
@@ -360,8 +360,8 @@ impl RenderOnce for Modal {
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.; let x = bounds.center().x - self.width / 2.;
let mut padding_right = px(16.); let mut padding_right = px(8.);
let mut padding_left = px(16.); let mut padding_left = px(8.);
if let Some(pl) = self.style.padding.left { if let Some(pl) = self.style.padding.left {
padding_left = pl.to_pixels(self.width.into(), window.rem_size()); padding_left = pl.to_pixels(self.width.into(), window.rem_size());