refactor: Client keys and Identity (#61)

* .

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

* update new account
This commit is contained in:
reya
2025-06-17 07:16:16 +07:00
committed by GitHub
parent cc36adeafe
commit 440f17af18
31 changed files with 1460 additions and 586 deletions

View File

@@ -10,11 +10,13 @@ path = "src/main.rs"
[dependencies]
ui = { path = "../ui" }
identity = { path = "../identity" }
theme = { path = "../theme" }
common = { path = "../common" }
global = { path = "../global" }
chats = { path = "../chats" }
settings = { path = "../settings" }
client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
@@ -35,5 +37,4 @@ smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -2,13 +2,15 @@ use std::sync::Arc;
use anyhow::Error;
use chats::{ChatRegistry, RoomEmitter};
use client_keys::ClientKeys;
use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, IntoElement,
div, impl_internal_actions, px, relative, App, AppContext, Axis, Context, Entity, IntoElement,
ParentElement, Render, Styled, Subscription, Task, Window,
};
use identity::Identity;
use nostr_connect::prelude::*;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
@@ -17,7 +19,8 @@ use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
use ui::dock_area::{DockArea, DockItem};
use ui::{ContextModal, IconName, Root, Sizable, TitleBar};
use ui::modal::ModalButtonProps;
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
use crate::views::chat::{self, Chat};
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
@@ -62,7 +65,7 @@ pub struct ChatSpace {
dock: Entity<DockArea>,
titlebar: bool,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 2]>,
subscriptions: SmallVec<[Subscription; 4]>,
}
impl ChatSpace {
@@ -78,8 +81,81 @@ impl ChatSpace {
cx.new(|cx| {
let chats = ChatRegistry::global(cx);
let client_keys = ClientKeys::global(cx);
let identity = Identity::global(cx);
let mut subscriptions = smallvec![];
// Observe the client keys and show an alert modal if they fail to initialize
subscriptions.push(cx.observe_in(
&client_keys,
window,
|_this: &mut Self, state, window, cx| {
if !state.read(cx).has_keys() {
window.open_modal(cx, |this, _window, cx| {
const DESCRIPTION: &str =
"Allow Coop to read the client keys stored in Keychain to continue";
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.button_props(
ModalButtonProps::default()
.cancel_text("Create New Keys")
.ok_text("Allow"),
)
.child(
div()
.px_10()
.w_full()
.h_40()
.flex()
.flex_col()
.gap_1()
.items_center()
.justify_center()
.text_center()
.text_sm()
.child(
div()
.font_semibold()
.text_color(cx.theme().text_muted)
.child("Warning"),
)
.child(div().line_height(relative(1.4)).child(DESCRIPTION)),
)
.on_cancel(|_, _window, cx| {
ClientKeys::global(cx).update(cx, |this, cx| {
this.new_keys(cx);
});
// true: Close modal
true
})
.on_ok(|_, window, cx| {
ClientKeys::global(cx).update(cx, |this, cx| {
this.load(window, cx);
});
// true: Close modal
true
})
});
}
},
));
// Observe the identity and show onboarding if it fails to initialize
subscriptions.push(cx.observe_in(
&identity,
window,
|this: &mut Self, state, window, cx| {
if !state.read(cx).has_profile() {
this.open_onboarding(window, cx);
} else {
this.open_chats(window, cx);
}
},
));
// Automatically load messages when chat panel opens
subscriptions.push(cx.observe_new::<Chat>(|this: &mut Chat, window, cx| {
if let Some(window) = window {
@@ -91,7 +167,7 @@ impl ChatSpace {
subscriptions.push(cx.subscribe_in(
&chats,
window,
|this: &mut ChatSpace, _state, event, window, cx| {
|this: &mut Self, _state, event, window, cx| {
if let RoomEmitter::Open(room) = event {
if let Some(room) = room.upgrade() {
this.dock.update(cx, |this, cx| {
@@ -223,11 +299,10 @@ impl ChatSpace {
}
}
fn logout(&self, _window: &mut Window, cx: &mut App) {
cx.background_spawn(async move {
shared_state().unset_signer().await;
})
.detach();
fn logout(&self, window: &mut Window, cx: &mut App) {
Identity::global(cx).update(cx, |this, cx| {
this.unload(window, cx);
});
}
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {

View File

@@ -1,13 +1,11 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Error;
use asset::Assets;
use auto_update::AutoUpdater;
use chats::ChatRegistry;
use global::constants::APP_ID;
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::constants::{APP_ID, KEYRING_BUNKER, KEYRING_USER_PATH};
use global::{shared_state, NostrSignal};
use gpui::{
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
@@ -17,7 +15,6 @@ use gpui::{
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use nostr_connect::prelude::*;
use theme::Theme;
use ui::Root;
@@ -94,6 +91,10 @@ fn main() {
ui::init(cx);
// Initialize settings
settings::init(cx);
// Initialize client keys
client_keys::init(cx);
// Initialize identity
identity::init(window, cx);
// Initialize auto update
auto_update::init(cx);
// Initialize chat state
@@ -102,42 +103,6 @@ fn main() {
// Initialize chatspace (or workspace)
let chatspace = chatspace::init(window, cx);
let async_chatspace = chatspace.downgrade();
let async_chatspace_clone = async_chatspace.clone();
// Read user's credential
let read_credential = cx.read_credentials(KEYRING_USER_PATH);
cx.spawn_in(window, async move |_, cx| {
if let Ok(Some((user, secret))) = read_credential.await {
cx.update(|window, cx| {
if let Ok(signer) = extract_credential(&user, secret) {
cx.background_spawn(async move {
if let Err(e) = shared_state().set_signer(signer).await {
log::error!("Signer error: {}", e);
}
})
.detach();
} else {
async_chatspace
.update(cx, |this, cx| {
this.open_onboarding(window, cx);
})
.ok();
}
})
.ok();
} else {
cx.update(|window, cx| {
async_chatspace
.update(cx, |this, cx| {
this.open_onboarding(window, cx);
})
.ok();
})
.ok();
}
})
.detach();
// Spawn a task to handle events from nostr channel
cx.spawn_in(window, async move |_, cx| {
@@ -148,14 +113,14 @@ fn main() {
match signal {
NostrSignal::SignerUpdated => {
async_chatspace_clone
async_chatspace
.update(cx, |this, cx| {
this.open_chats(window, cx);
})
.ok();
}
NostrSignal::SignerUnset => {
async_chatspace_clone
async_chatspace
.update(cx, |this, cx| {
this.open_onboarding(window, cx);
})
@@ -190,22 +155,6 @@ fn main() {
});
}
fn extract_credential(user: &str, secret: Vec<u8>) -> Result<impl NostrSigner, Error> {
if user == KEYRING_BUNKER {
let value = String::from_utf8(secret)?;
let uri = NostrConnectURI::parse(value)?;
let client_keys = shared_state().client_signer.clone();
let signer = NostrConnect::new(uri, client_keys, Duration::from_secs(300), None)?;
Ok(signer.into_nostr_signer())
} else {
let secret_key = SecretKey::from_slice(&secret)?;
let keys = Keys::new(secret_key);
Ok(keys.into_nostr_signer())
}
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();

View File

@@ -17,6 +17,7 @@ use gpui::{
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use serde::Deserialize;
@@ -220,7 +221,7 @@ impl Chat {
// TODO: find a better way to prevent duplicate messages during optimistic updates
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool {
let Some(account) = shared_state().identity() else {
let Some(account) = Identity::get_global(cx).profile() else {
return false;
};
@@ -372,7 +373,7 @@ impl Chat {
self.uploading(true, cx);
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let nip96 = AppSettings::get_global(cx).settings.media_server.clone();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
@@ -546,8 +547,8 @@ impl Chat {
return div().id(ix);
};
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
let message = message.borrow();

View File

@@ -306,7 +306,7 @@ impl Render for Compose {
const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let label: SharedString = if self.selected.read(cx).len() > 1 {
"Create Group DM".into()

View File

@@ -1,15 +1,17 @@
use std::sync::Arc;
use std::time::Duration;
use client_keys::ClientKeys;
use common::handle_auth::CoopAuthUrlHandler;
use common::string_to_qr;
use global::constants::{APP_NAME, KEYRING_BUNKER, KEYRING_USER_PATH};
use global::shared_state;
use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
};
use identity::Identity;
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
@@ -20,21 +22,6 @@ use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::{ContextModal, Disableable, Sizable, StyledExt};
const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
const NOSTR_CONNECT_TIMEOUT: u64 = 300;
#[derive(Debug, Clone)]
struct CoopAuthUrlHandler;
impl AuthUrlHandler for CoopAuthUrlHandler {
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<()>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str())?;
Ok(())
})
}
}
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
@@ -72,9 +59,9 @@ impl Login {
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
//
// Direct connection initiated by the client
let connection_string = cx.new(|_cx| {
let connection_string = cx.new(|cx| {
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let client_keys = shared_state().client_signer.clone();
let client_keys = ClientKeys::get_global(cx).keys();
NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME)
});
@@ -90,6 +77,7 @@ impl Login {
let error = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Subscribe to key input events and process login when the user presses enter
subscriptions.push(
cx.subscribe_in(&key_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
@@ -98,6 +86,7 @@ impl Login {
}),
);
// Subscribe to relay input events and change relay when the user presses enter
subscriptions.push(
cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
@@ -106,41 +95,38 @@ impl Login {
}),
);
subscriptions.push(cx.observe_new::<NostrConnectURI>(
move |connection_string, _window, cx| {
if let Ok(mut signer) = NostrConnect::new(
connection_string.to_owned(),
shared_state().client_signer.clone(),
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
) {
// Automatically open remote signer's webpage when received auth url
signer.auth_url_handler(CoopAuthUrlHandler);
// Observe the Connect URI that changes when the relay is changed
subscriptions.push(cx.observe_new::<NostrConnectURI>(move |uri, _window, cx| {
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
async_active_signer
.update(cx, |this, cx| {
*this = Some(signer);
cx.notify();
})
.ok();
}
if let Ok(mut signer) = NostrConnect::new(uri.to_owned(), client_keys, timeout, None) {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
// Update the QR Image with the new connection string
async_qr_image
async_active_signer
.update(cx, |this, cx| {
*this = string_to_qr(&connection_string.to_string());
*this = Some(signer);
cx.notify();
})
.ok();
},
));
}
// Update the QR Image with the new connection string
async_qr_image
.update(cx, |this, cx| {
*this = string_to_qr(&uri.to_string());
cx.notify();
})
.ok();
}));
subscriptions.push(cx.observe_in(
&connection_string,
window,
|this, entity, _window, cx| {
let connection_string = entity.read(cx).clone();
let client_keys = shared_state().client_signer.clone();
let client_keys = ClientKeys::get_global(cx).keys();
// Update the QR Image with the new connection string
this.qr_image.update(cx, |this, cx| {
@@ -148,58 +134,35 @@ impl Login {
cx.notify();
});
if let Ok(mut signer) = NostrConnect::new(
match NostrConnect::new(
connection_string,
client_keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
) {
// Automatically open remote signer's webpage when received auth url
signer.auth_url_handler(CoopAuthUrlHandler);
Ok(mut signer) => {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
this.active_signer.update(cx, |this, cx| {
*this = Some(signer);
cx.notify();
});
this.active_signer.update(cx, |this, cx| {
*this = Some(signer);
cx.notify();
});
}
Err(_) => {
log::error!("Failed to create Nostr Connect")
}
}
},
));
subscriptions.push(
cx.observe_in(&active_signer, window, |_this, entity, window, cx| {
if let Some(signer) = entity.read(cx).clone() {
let (tx, rx) = oneshot::channel::<Option<NostrConnectURI>>();
cx.background_spawn(async move {
if let Ok(bunker_uri) = signer.bunker_uri().await {
tx.send(Some(bunker_uri)).ok();
if let Err(e) = shared_state().set_signer(signer).await {
log::error!("{}", e);
}
} else {
tx.send(None).ok();
}
})
.detach();
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(uri)) = rx.await {
this.update(cx, |this, cx| {
this.save_bunker(&uri, cx);
})
.ok();
} else {
cx.update(|window, cx| {
window.push_notification(
Notification::error("Connection failed"),
cx,
);
})
.ok();
}
})
.detach();
cx.observe_in(&active_signer, window, |this, entity, window, cx| {
if let Some(mut signer) = entity.read(cx).clone() {
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
// Wait for connection from remote signer
this.wait_for_connection(signer, window, cx);
}
}),
);
@@ -222,90 +185,212 @@ impl Login {
if self.is_logging_in {
return;
};
// Prevent duplicate login requests
self.set_logging_in(true, cx);
let client_keys = shared_state().client_signer.clone();
let content = self.key_input.read(cx).value();
if content.starts_with("nsec1") {
let Ok(keys) = SecretKey::parse(content.as_ref()).map(Keys::new) else {
self.set_error("Secret key is not valid", cx);
return;
};
// Active signer is no longer needed
self.shutdown_active_signer(cx);
// Save these keys to the OS storage for further logins
self.save_keys(&keys, cx);
// Set signer with this keys in the background
cx.background_spawn(async move {
if let Err(e) = shared_state().set_signer(keys).await {
log::error!("{}", e);
}
})
.detach();
} else if content.starts_with("bunker://") {
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
self.set_error("Bunker URL is not valid", cx);
return;
};
// Active signer is no longer needed
self.shutdown_active_signer(cx);
match NostrConnect::new(
uri.clone(),
client_keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 2),
None,
) {
Ok(signer) => {
let (tx, rx) = oneshot::channel::<Option<NostrConnectURI>>();
// Set signer with this remote signer in the background
cx.background_spawn(async move {
if let Ok(bunker_uri) = signer.bunker_uri().await {
tx.send(Some(bunker_uri)).ok();
if let Err(e) = shared_state().set_signer(signer).await {
log::error!("{}", e);
}
} else {
tx.send(None).ok();
}
})
.detach();
// Handle error
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(uri)) = rx.await {
this.update(cx, |this, cx| {
this.save_bunker(&uri, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.set_error(
"Connection to the Remote Signer failed or timed out",
cx,
);
})
.ok();
}
})
.detach();
}
Err(e) => {
self.set_error(e.to_string(), cx);
}
}
} else {
self.set_error("You must provide a valid Private Key or Bunker.", cx);
// Content can be secret key or bunker://
match self.key_input.read(cx).value().to_string() {
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
s if s.starts_with("bunker://") => self.login_with_bunker(&s, window, cx),
_ => self.set_error("You must provide a valid Private Key or Bunker.", cx),
};
}
fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let current_view = cx.entity().downgrade();
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
window.open_modal(cx, move |this, _window, cx| {
let weak_input = weak_input.clone();
let view_cancel = current_view.clone();
let view_ok = current_view.clone();
let label: SharedString = if content.starts_with("nsec1") {
"Set password to encrypt your key *".into()
} else {
"Password to decrypt your key *".into()
};
let description: Option<SharedString> = if content.starts_with("ncryptsec1") {
Some("Coop will only stored the encrypted version".into())
} else {
None
};
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, _window, cx| {
view_cancel
.update(cx, |this, cx| {
this.set_error("Password is required", cx);
})
.ok();
true
})
.on_ok(move |_, window, cx| {
let value = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
view_ok
.update(cx, |this, cx| {
if let Some(password) = value {
this.login_with_keys(password.to_string(), window, cx);
} else {
this.set_error("Password is required", cx);
}
})
.ok();
true
})
.child(
div()
.pt_4()
.px_4()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(label)
.child(TextInput::new(&pwd_input).small())
.when_some(description, |this, description| {
this.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(description),
)
}),
)
});
}
fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context<Self>) {
let value = self.key_input.read(cx).value().to_string();
let secret_key = if value.starts_with("nsec1") {
SecretKey::parse(&value).ok()
} else if value.starts_with("ncryptsec1") {
EncryptedSecretKey::from_bech32(&value)
.map(|enc| enc.decrypt(&password).ok())
.unwrap_or_default()
} else {
None
};
if let Some(secret_key) = secret_key {
// Active signer is no longer needed
self.shutdown_active_signer(cx);
let keys = Keys::new(secret_key);
Identity::global(cx).update(cx, |this, cx| {
this.write_keys(&keys, password, cx);
this.set_signer(keys, window, cx);
});
} else {
self.set_error("Secret Key is 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 URL is not valid", cx);
return;
};
let client_keys = ClientKeys::get_global(cx).keys();
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 2);
let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else {
self.set_error("Failed to create remote signer", cx);
return;
};
// Active signer is no longer needed
self.shutdown_active_signer(cx);
// Automatically open auth url
signer.auth_url_handler(CoopAuthUrlHandler);
let (tx, rx) = oneshot::channel::<Option<(NostrConnect, NostrConnectURI)>>();
// Verify remote signer connection
cx.background_spawn(async move {
if let Ok(bunker_uri) = signer.bunker_uri().await {
tx.send(Some((signer, bunker_uri))).ok();
} else {
tx.send(None).ok();
}
})
.detach();
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((signer, uri))) = rx.await {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
} else {
this.update(cx, |this, cx| {
let msg = "Connection to the Remote Signer failed or timed out";
this.set_error(msg, cx);
})
.ok();
}
})
.detach();
}
fn wait_for_connection(
&mut self,
signer: NostrConnect,
window: &mut Window,
cx: &mut Context<Self>,
) {
let (tx, rx) = oneshot::channel::<Option<(NostrConnectURI, NostrConnect)>>();
cx.background_spawn(async move {
if let Ok(bunker_uri) = signer.bunker_uri().await {
tx.send(Some((bunker_uri, signer))).ok();
} else {
tx.send(None).ok();
}
})
.detach();
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some((uri, signer))) = rx.await {
cx.update(|window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&uri, cx);
this.set_signer(signer, window, cx);
});
})
.ok();
} else {
cx.update(|window, cx| {
window.push_notification(Notification::error("Connection failed"), cx);
// Refresh the active signer
this.update(cx, |this, cx| {
this.change_relay(window, cx);
})
.ok();
})
.ok();
}
})
.detach();
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str())
else {
@@ -313,7 +398,7 @@ impl Login {
return;
};
let client_keys = shared_state().client_signer.clone();
let client_keys = ClientKeys::get_global(cx).keys();
let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop");
self.connection_string.update(cx, |this, cx| {
@@ -322,40 +407,6 @@ impl Login {
});
}
fn save_keys(&self, keys: &Keys, cx: &mut Context<Self>) {
let save_credential = cx.write_credentials(
KEYRING_USER_PATH,
keys.public_key().to_hex().as_str(),
keys.secret_key().as_secret_bytes(),
);
cx.background_spawn(async move {
if let Err(e) = save_credential.await {
log::error!("Failed to save keys: {}", e)
}
})
.detach();
}
fn save_bunker(&self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
let mut value = uri.to_string();
// Remove the secret param if it exists
if let Some(secret) = uri.secret() {
value = value.replace(secret, "");
}
let save_credential =
cx.write_credentials(KEYRING_USER_PATH, KEYRING_BUNKER, value.as_bytes());
cx.background_spawn(async move {
if let Err(e) = save_credential.await {
log::error!("Failed to save the Bunker URI: {}", e)
}
})
.detach();
}
fn shutdown_active_signer(&self, cx: &Context<Self>) {
if let Some(signer) = self.active_signer.read(cx).clone() {
cx.background_spawn(async move {

View File

@@ -1,6 +1,5 @@
use async_utility::task::spawn;
use common::nip96_upload;
use global::constants::KEYRING_USER_PATH;
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -8,6 +7,7 @@ use gpui::{
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
};
use identity::Identity;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
@@ -16,7 +16,7 @@ use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::input::{InputState, TextInput};
use ui::popup_menu::PopupMenu;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
@@ -65,37 +65,71 @@ impl NewAccount {
}
}
fn submit(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let keys = Keys::generate();
let mut metadata = Metadata::new().display_name(name).about(bio);
if let Ok(url) = Url::parse(&avatar) {
metadata = metadata.picture(url);
};
let save_credential = cx.write_credentials(
KEYRING_USER_PATH,
keys.public_key().to_hex().as_str(),
keys.secret_key().as_secret_bytes(),
);
let current_view = cx.entity().downgrade();
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let weak_input = pwd_input.downgrade();
cx.background_spawn(async move {
if let Err(e) = save_credential.await {
log::error!("Failed to save keys: {}", e)
};
shared_state().new_account(keys, metadata).await;
})
.detach();
window.open_modal(cx, move |this, _window, _cx| {
let metadata = metadata.clone();
let weak_input = weak_input.clone();
let view_cancel = current_view.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |_this, cx| {
window.push_notification("Password is invalid", cx)
})
.ok();
true
})
.on_ok(move |_, _window, cx| {
let metadata = metadata.clone();
let value = weak_input
.read_with(cx, |state, _cx| state.value().to_owned())
.ok();
if let Some(password) = value {
Identity::global(cx).update(cx, |this, cx| {
this.new_identity(Keys::generate(), password.to_string(), metadata, cx);
});
}
true
})
.child(
div()
.pt_4()
.px_4()
.w_full()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child("Set password to encrypt your key *")
.child(TextInput::new(&pwd_input).small()),
)
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let nip96 = AppSettings::get_global(cx).settings.media_server.clone();
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,

View File

@@ -1,12 +1,25 @@
use anyhow::anyhow;
use common::profile::RenderProfile;
use global::constants::ACCOUNT_D;
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::checkbox::Checkbox;
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::popup_menu::PopupMenu;
use ui::{Icon, IconName, StyledExt};
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
use crate::chatspace;
@@ -16,6 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
pub struct Onboarding {
name: SharedString,
local_account: Entity<Option<Profile>>,
loading: bool,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
@@ -26,14 +41,73 @@ impl Onboarding {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let local_account = cx.new(|_| None);
let task = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(ACCOUNT_D)
.limit(1);
if let Some(event) = shared_state()
.client
.database()
.query(filter)
.await?
.first_owned()
{
let public_key = event
.tags
.public_keys()
.copied()
.collect_vec()
.first()
.cloned()
.unwrap();
let metadata = shared_state()
.client
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
let profile = Profile::new(public_key, metadata);
Ok(profile)
} else {
Err(anyhow!("Not found"))
}
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(profile) = task.await {
this.update(cx, |this, cx| {
this.local_account.update(cx, |this, cx| {
*this = Some(profile);
cx.notify();
});
})
.ok();
}
})
.detach();
Self {
local_account,
name: "Onboarding".into(),
loading: false,
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Panel for Onboarding {
@@ -71,6 +145,9 @@ impl Render for Onboarding {
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "Secure Communication on Nostr.";
let auto_login = AppSettings::get_global(cx).settings.auto_login;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
div()
.py_4()
.size_full()
@@ -78,9 +155,9 @@ impl Render for Onboarding {
.flex_col()
.items_center()
.justify_center()
.gap_10()
.child(
div()
.mb_10()
.flex()
.flex_col()
.items_center()
@@ -104,31 +181,121 @@ impl Render for Onboarding {
.child(div().text_color(cx.theme().text_muted).child(SUBTITLE)),
),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
.map(|this| {
if let Some(profile) = self.local_account.read(cx).as_ref() {
this.relative()
.child(
div()
.id("account")
.mb_3()
.h_10()
.w_72()
.bg(cx.theme().element_background)
.text_color(cx.theme().element_foreground)
.rounded_lg()
.text_sm()
.map(|this| {
if self.loading {
this.child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Indicator::new().small()),
)
} else {
this.child(
div()
.h_full()
.flex()
.items_center()
.justify_center()
.gap_2()
.child("Continue as")
.child(
div()
.flex()
.items_center()
.gap_1()
.font_semibold()
.child(
Avatar::new(
profile.render_avatar(proxy),
)
.size(rems(1.5)),
)
.child(
div()
.pb_px()
.child(profile.render_name()),
),
),
)
}
})
.hover(|this| this.bg(cx.theme().element_hover))
.on_click(cx.listener(|this, _, window, cx| {
this.set_loading(true, cx);
Identity::global(cx).update(cx, |this, cx| {
this.load(window, cx);
});
})),
)
.child(
Checkbox::new("auto_login")
.label("Automatically log in next time")
.checked(auto_login)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.auto_login = !this.settings.auto_login;
cx.notify();
})
}),
)
.child(
div().w_24().absolute().bottom_4().right_4().child(
Button::new("unload")
.icon(IconName::Logout)
.label("Logout")
.ghost()
.small()
.disabled(self.loading)
.on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| {
this.unload(window, cx);
});
}),
),
)
} else {
this.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::new_account(window, cx);
})),
)
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
),
)
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |_, _, window, cx| {
chatspace::login(window, cx);
})),
),
)
}
})
}
}

View File

@@ -1,13 +1,11 @@
use common::profile::RenderProfile;
use global::{
constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER},
shared_state,
};
use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER};
use gpui::{
div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context,
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render,
StatefulInteractiveElement, Styled, Window,
};
use identity::Identity;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::{
@@ -33,7 +31,7 @@ impl Preferences {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
let media_server = AppSettings::get_global(cx)
.settings()
.settings
.media_server
.to_string();
@@ -84,7 +82,7 @@ impl Render for Preferences {
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
let input_state = self.media_input.downgrade();
let settings = AppSettings::get_global(cx).settings();
let settings = AppSettings::get_global(cx).settings.as_ref();
div()
.track_focus(&self.focus_handle)
@@ -106,7 +104,7 @@ impl Render for Preferences {
.font_semibold()
.child("Account"),
)
.when_some(shared_state().identity(), |this, profile| {
.when_some(Identity::get_global(cx).profile(), |this, profile| {
this.child(
div()
.w_full()

View File

@@ -105,7 +105,7 @@ impl Profile {
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nip96 = AppSettings::get_global(cx).settings().media_server.clone();
let nip96 = AppSettings::get_global(cx).settings.media_server.clone();
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,

View File

@@ -60,7 +60,7 @@ impl DisplayRoom {
impl RenderOnce for DisplayRoom {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
self.base
.id(self.ix)

View File

@@ -17,6 +17,7 @@ use gpui::{
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
Window,
};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
@@ -70,7 +71,7 @@ impl Sidebar {
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None);
let trusted_only = AppSettings::get_global(cx).settings().only_show_trusted;
let trusted_only = AppSettings::get_global(cx).settings.only_show_trusted;
let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
@@ -349,7 +350,7 @@ impl Sidebar {
}
fn render_account(&self, profile: &Profile, cx: &Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
div()
.px_3()
@@ -491,10 +492,9 @@ impl Render for Sidebar {
.flex_col()
.gap_3()
// Account
.when_some(
shared_state().identity.read_blocking().as_ref(),
|this, profile| this.child(self.render_account(profile, cx)),
)
.when_some(Identity::get_global(cx).profile(), |this, profile| {
this.child(self.render_account(&profile, cx))
})
// Search Input
.child(
div().px_3().w_full().h_7().flex_none().child(

View File

@@ -65,6 +65,8 @@ impl Render for Startup {
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.gap_6()
.child(
svg()
@@ -74,13 +76,10 @@ impl Render for Startup {
)
.child(
div()
.w_24()
.flex()
.items_center()
.gap_1p5()
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child("Connection in progress")
.justify_center()
.child(Indicator::new().small()),
),
)