refactor: Client keys and Identity (#61)
* . * . * . * . * refactor client keys * . * . * refactor * . * . * . * update new account
This commit is contained in:
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
global = { path = "../global" }
|
||||
identity = { path = "../identity" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -9,6 +9,7 @@ use global::shared_state;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use room::RoomKind;
|
||||
@@ -77,7 +78,7 @@ impl ChatRegistry {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
// When the ChatRegistry is created, load all rooms from the local database
|
||||
subscriptions.push(cx.observe_new::<ChatRegistry>(|this, window, cx| {
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load_rooms(window, cx);
|
||||
}
|
||||
@@ -162,7 +163,7 @@ impl ChatRegistry {
|
||||
/// 4. Creates Room entities for each unique room
|
||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = &shared_state().client;
|
||||
let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else {
|
||||
let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -288,7 +289,7 @@ impl ChatRegistry {
|
||||
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
let author = event.pubkey;
|
||||
let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else {
|
||||
let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use common::profile::RenderProfile;
|
||||
use common::{compare, room_hash};
|
||||
use global::shared_state;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||
use identity::Identity;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
@@ -165,8 +166,8 @@ impl Room {
|
||||
/// # Returns
|
||||
///
|
||||
/// The Profile of the first member in the room
|
||||
pub fn first_member(&self, _cx: &App) -> Profile {
|
||||
let Some(account) = shared_state().identity() else {
|
||||
pub fn first_member(&self, cx: &App) -> Profile {
|
||||
let Some(account) = Identity::get_global(cx).profile() else {
|
||||
return shared_state().person(&self.members[0]);
|
||||
};
|
||||
|
||||
@@ -250,7 +251,7 @@ impl Room {
|
||||
/// - For a direct message: the other person's avatar
|
||||
/// - For a group chat: None
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
|
||||
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
||||
|
||||
if let Some(picture) = self.picture.as_ref() {
|
||||
picture.clone()
|
||||
@@ -550,9 +551,9 @@ impl Room {
|
||||
&self,
|
||||
content: &str,
|
||||
replies: Option<&Vec<Message>>,
|
||||
_cx: &App,
|
||||
cx: &App,
|
||||
) -> Option<Message> {
|
||||
let author = shared_state().identity()?;
|
||||
let author = Identity::get_global(cx).profile()?;
|
||||
let public_key = author.public_key();
|
||||
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
||||
|
||||
@@ -633,7 +634,7 @@ impl Room {
|
||||
let subject = self.subject.clone();
|
||||
let picture = self.picture.clone();
|
||||
let public_keys = Arc::clone(&self.members);
|
||||
let backup = AppSettings::get_global(cx).settings().backup_messages;
|
||||
let backup = AppSettings::get_global(cx).settings.backup_messages;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = shared_state().client.signer().await?;
|
||||
|
||||
14
crates/client_keys/Cargo.toml
Normal file
14
crates/client_keys/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "client_keys"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
121
crates/client_keys/src/lib.rs
Normal file
121
crates/client_keys/src/lib.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use global::{constants::KEYRING_URL, shared_state};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ClientKeys::set_global(cx.new(ClientKeys::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalClientKeys(Entity<ClientKeys>);
|
||||
|
||||
impl Global for GlobalClientKeys {}
|
||||
|
||||
pub struct ClientKeys {
|
||||
keys: Option<Keys>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl ClientKeys {
|
||||
/// Retrieve the Global Client Keys instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalClientKeys>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Client Keys instance
|
||||
pub fn get_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalClientKeys>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the Global Client Keys instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalClientKeys(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
keys: None,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let read_client_keys = cx.read_credentials(KEYRING_URL);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Some((_, secret))) = read_client_keys.await {
|
||||
// Update keys
|
||||
this.update(cx, |this, cx| {
|
||||
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
|
||||
this.set_keys(None, false, cx);
|
||||
return;
|
||||
};
|
||||
let keys = Keys::new(secret_key);
|
||||
this.set_keys(Some(keys), false, cx);
|
||||
})
|
||||
.ok();
|
||||
} else if shared_state().first_run {
|
||||
// Generate a new keys and update
|
||||
this.update(cx, |this, cx| {
|
||||
this.new_keys(cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_keys(None, false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn set_keys(&mut self, keys: Option<Keys>, persist: bool, cx: &mut Context<Self>) {
|
||||
if let Some(keys) = keys.clone() {
|
||||
if persist {
|
||||
let write_keys = cx.write_credentials(
|
||||
KEYRING_URL,
|
||||
keys.public_key().to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(e) = write_keys.await {
|
||||
log::error!("Failed to save the client keys: {e}")
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
self.keys = keys;
|
||||
// Make sure notify the UI after keys changes
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn new_keys(&mut self, cx: &mut Context<Self>) {
|
||||
self.set_keys(Some(Keys::generate()), true, cx);
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> Keys {
|
||||
self.keys
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.expect("Keys should always be initialized")
|
||||
}
|
||||
|
||||
pub fn has_keys(&self) -> bool {
|
||||
self.keys.is_some()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
@@ -16,4 +17,5 @@ smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
webbrowser = "1.0.4"
|
||||
qrcode-generator = "5.0.0"
|
||||
|
||||
13
crates/common/src/handle_auth.rs
Normal file
13
crates/common/src/handle_auth.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use nostr_connect::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub 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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use nostr_sdk::prelude::*;
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
|
||||
pub mod debounced_delay;
|
||||
pub mod handle_auth;
|
||||
pub mod profile;
|
||||
|
||||
pub async fn nip96_upload(
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr-keyring.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
pub const APP_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
|
||||
pub const KEYRING_PATH: &str = "Coop Safe Storage";
|
||||
pub const KEYRING_USER_PATH: &str = "coop";
|
||||
pub const KEYRING_BUNKER: &str = "bunker";
|
||||
pub const KEYRING_URL: &str = "Coop Safe Storage";
|
||||
|
||||
pub const ACCOUNT_D: &str = "coop:account";
|
||||
pub const SETTINGS_D: &str = "coop:settings";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
@@ -12,9 +13,27 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://user.kindpag.es",
|
||||
"wss://relaydiscovery.com",
|
||||
];
|
||||
|
||||
/// NIP65 Relays. Used for new account
|
||||
pub const NIP65_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
/// Messaging Relays. Used for new account
|
||||
pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
|
||||
|
||||
/// Search Relays.
|
||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
|
||||
|
||||
/// Default relay for Nostr Connect
|
||||
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
|
||||
|
||||
/// Default timeout for Nostr Connect
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 300;
|
||||
|
||||
/// Unique ID for new message subscription.
|
||||
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
|
||||
/// Unique ID for all messages subscription.
|
||||
@@ -38,10 +57,3 @@ pub const NIP96_SERVER: &str = "https://nostrmedia.com";
|
||||
|
||||
pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048;
|
||||
pub(crate) const BATCH_CHANNEL_LIMIT: usize = 1024;
|
||||
pub(crate) const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
|
||||
pub(crate) const NIP65_RELAYS: [&str; 4] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
//! Global state management for the Nostr client application.
|
||||
//!
|
||||
//! This module provides a singleton global state that manages:
|
||||
//! - Nostr client connections and event handling
|
||||
//! - User identity and profile management
|
||||
//! - Batched metadata fetching for performance
|
||||
//! - Cross-component communication via channels
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::mem;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use std::{fs, mem};
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
|
||||
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
|
||||
};
|
||||
use nostr_keyring::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use paths::nostr_file;
|
||||
use smol::lock::RwLock;
|
||||
|
||||
use crate::constants::{
|
||||
BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT, KEYRING_PATH, NIP17_RELAYS, NIP65_RELAYS,
|
||||
};
|
||||
use crate::constants::{BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT};
|
||||
use crate::paths::support_dir;
|
||||
|
||||
pub mod constants;
|
||||
pub mod paths;
|
||||
@@ -50,11 +40,9 @@ pub enum NostrSignal {
|
||||
pub struct Globals {
|
||||
/// The Nostr SDK client
|
||||
pub client: Client,
|
||||
/// Cryptographic keys for signing Nostr events
|
||||
pub client_signer: Keys,
|
||||
/// Current user's profile information (pubkey and metadata)
|
||||
pub identity: RwLock<Option<Profile>>,
|
||||
/// Auto-close options for subscriptions to prevent memory leaks
|
||||
/// Determines if this is the first time user run Coop
|
||||
pub first_run: bool,
|
||||
/// Auto-close options for subscriptions
|
||||
pub auto_close: Option<SubscribeAutoCloseOptions>,
|
||||
/// Channel sender for broadcasting global Nostr events to UI
|
||||
pub global_sender: smol::channel::Sender<NostrSignal>,
|
||||
@@ -78,18 +66,7 @@ pub fn shared_state() -> &'static Globals {
|
||||
.install_default()
|
||||
.ok();
|
||||
|
||||
let keyring = NostrKeyring::new(KEYRING_PATH);
|
||||
// Get the client signer or generate a new one if it doesn't exist
|
||||
let client_signer = if let Ok(keys) = keyring.get("client") {
|
||||
keys
|
||||
} else {
|
||||
let keys = Keys::generate();
|
||||
if let Err(e) = keyring.set("client", &keys) {
|
||||
log::error!("Failed to save client keys: {}", e);
|
||||
}
|
||||
keys
|
||||
};
|
||||
|
||||
let first_run = is_first_run().unwrap_or(true);
|
||||
let opts = Options::new().gossip(true);
|
||||
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
|
||||
|
||||
@@ -101,12 +78,11 @@ pub fn shared_state() -> &'static Globals {
|
||||
|
||||
Globals {
|
||||
client: ClientBuilder::default().database(lmdb).opts(opts).build(),
|
||||
identity: RwLock::new(None),
|
||||
persons: RwLock::new(BTreeMap::new()),
|
||||
auto_close: Some(
|
||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
|
||||
),
|
||||
client_signer,
|
||||
first_run,
|
||||
global_sender,
|
||||
global_receiver,
|
||||
batch_sender,
|
||||
@@ -120,6 +96,7 @@ impl Globals {
|
||||
pub async fn start(&self) {
|
||||
self.connect().await;
|
||||
self.subscribe_for_app_updates().await;
|
||||
self.preload_metadata().await;
|
||||
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
|
||||
@@ -225,112 +202,21 @@ impl Globals {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a new signer for the client and updates user identity
|
||||
pub async fn set_signer<S>(&self, signer: S) -> Result<(), Error>
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Update signer
|
||||
self.client.set_signer(signer).await;
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = shared_state()
|
||||
.client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
let profile = Profile::new(public_key, metadata);
|
||||
let mut identity_guard = self.identity.write().await;
|
||||
// Update the identity
|
||||
*identity_guard = Some(profile);
|
||||
|
||||
// Subscribe for user's data
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
shared_state().subscribe_for_user_data().await;
|
||||
});
|
||||
|
||||
// Notify GPUi via the global channel
|
||||
self.global_sender.send(NostrSignal::SignerUpdated).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unset_signer(&self) {
|
||||
self.client.reset().await;
|
||||
|
||||
if let Ok(signer) = self.client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
let file = support_dir().join(format!(".{}", public_key.to_bech32().unwrap()));
|
||||
fs::remove_file(&file).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.global_sender.send(NostrSignal::SignerUnset).await {
|
||||
log::error!("Failed to send signal to global channel: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new account with the given keys and metadata
|
||||
pub async fn new_account(&self, keys: Keys, metadata: Metadata) {
|
||||
let profile = Profile::new(keys.public_key(), metadata.clone());
|
||||
|
||||
// Update signer
|
||||
self.client.set_signer(keys).await;
|
||||
|
||||
// Set metadata
|
||||
self.client.set_metadata(&metadata).await.ok();
|
||||
|
||||
// Create relay list
|
||||
let builder = EventBuilder::new(Kind::RelayList, "").tags(
|
||||
NIP65_RELAYS.into_iter().filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay_metadata(url, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = self.client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
};
|
||||
|
||||
// Create messaging relay list
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
|
||||
NIP17_RELAYS.into_iter().filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay(url))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = self.client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send messaging relay list event: {}", e);
|
||||
};
|
||||
|
||||
let mut guard = self.identity.write().await;
|
||||
|
||||
// Update the identity
|
||||
*guard = Some(profile);
|
||||
|
||||
// Notify GPUi via the global channel
|
||||
self.global_sender
|
||||
.send(NostrSignal::SignerUpdated)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Subscribe
|
||||
self.subscribe_for_user_data().await;
|
||||
}
|
||||
|
||||
/// Returns the current user's profile (blocking)
|
||||
pub fn identity(&self) -> Option<Profile> {
|
||||
self.identity.read_blocking().as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Returns the current user's profile (async)
|
||||
pub async fn async_identity(&self) -> Option<Profile> {
|
||||
self.identity.read().await.as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Gets a person's profile from cache or creates default (blocking)
|
||||
pub fn person(&self, public_key: &PublicKey) -> Profile {
|
||||
let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) {
|
||||
@@ -354,7 +240,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Connects to bootstrap and configured relays
|
||||
async fn connect(&self) {
|
||||
pub(crate) async fn connect(&self) {
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
if let Err(e) = self.client.add_relay(relay).await {
|
||||
log::error!("Failed to add relay {}: {}", relay, e);
|
||||
@@ -374,13 +260,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Subscribes to user-specific data feeds (DMs, mentions, etc.)
|
||||
async fn subscribe_for_user_data(&self) {
|
||||
let Some(profile) = self.identity.read().await.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let public_key = profile.public_key();
|
||||
|
||||
pub async fn subscribe_for_user_data(&self, public_key: PublicKey) {
|
||||
let metadata = Filter::new()
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
@@ -435,7 +315,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Subscribes to application update notifications
|
||||
async fn subscribe_for_app_updates(&self) {
|
||||
pub(crate) async fn subscribe_for_app_updates(&self) {
|
||||
let coordinate = Coordinate {
|
||||
kind: Kind::Custom(32267),
|
||||
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
|
||||
@@ -458,8 +338,22 @@ impl Globals {
|
||||
log::info!("Subscribing to app updates...");
|
||||
}
|
||||
|
||||
pub(crate) async fn preload_metadata(&self) {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(100);
|
||||
if let Ok(events) = self.client.database().query(filter).await {
|
||||
for event in events.into_iter() {
|
||||
self.insert_person(&event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores an unwrapped event in local database with reference to original
|
||||
async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
|
||||
pub(crate) async fn set_unwrapped(
|
||||
&self,
|
||||
root: EventId,
|
||||
event: &Event,
|
||||
keys: &Keys,
|
||||
) -> Result<(), Error> {
|
||||
// Must be use the random generated keys to sign this event
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(vec![Tag::identifier(root), Tag::event(root)])
|
||||
@@ -473,9 +367,9 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Retrieves a previously unwrapped event from local database
|
||||
async fn get_unwrapped(&self, target: EventId) -> Result<Event, Error> {
|
||||
pub(crate) async fn get_unwrapped(&self, target: EventId) -> Result<Event, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(30078))
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.event(target)
|
||||
.limit(1);
|
||||
|
||||
@@ -487,7 +381,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Unwraps a gift-wrapped event and processes its contents
|
||||
async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) {
|
||||
pub(crate) async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) {
|
||||
let new_messages_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
let random_keys = Keys::generate();
|
||||
|
||||
@@ -527,7 +421,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Extracts public keys from contact list and queues metadata sync
|
||||
async fn extract_pubkeys_and_sync(&self, event: &Event) {
|
||||
pub(crate) async fn extract_pubkeys_and_sync(&self, event: &Event) {
|
||||
if let Ok(signer) = self.client.signer().await {
|
||||
if let Ok(public_key) = signer.get_public_key().await {
|
||||
if public_key == event.pubkey {
|
||||
@@ -539,7 +433,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Fetches metadata for a batch of public keys
|
||||
async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet<PublicKey>) {
|
||||
pub(crate) async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet<PublicKey>) {
|
||||
let kinds = vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
@@ -561,7 +455,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Inserts or updates a person's metadata from a Kind::Metadata event
|
||||
async fn insert_person(&self, event: &Event) {
|
||||
pub(crate) async fn insert_person(&self, event: &Event) {
|
||||
let metadata = Metadata::from_json(&event.content).ok();
|
||||
|
||||
self.persons
|
||||
@@ -577,7 +471,7 @@ impl Globals {
|
||||
}
|
||||
|
||||
/// Notifies UI of application updates via global channel
|
||||
async fn notify_update(&self, event: &Event) {
|
||||
pub(crate) async fn notify_update(&self, event: &Event) {
|
||||
let filter = Filter::new()
|
||||
.ids(event.tags.event_ids().copied())
|
||||
.kind(Kind::FileMetadata);
|
||||
@@ -596,3 +490,14 @@ impl Globals {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_first_run() -> Result<bool, anyhow::Error> {
|
||||
let flag = support_dir().join(".coop_first_run");
|
||||
|
||||
if !flag.exists() {
|
||||
fs::write(&flag, "")?;
|
||||
Ok(true) // First run
|
||||
} else {
|
||||
Ok(false) // Not first run
|
||||
}
|
||||
}
|
||||
|
||||
20
crates/identity/Cargo.toml
Normal file
20
crates/identity/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "identity"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
global = { path = "../global" }
|
||||
common = { path = "../common" }
|
||||
client_keys = { path = "../client_keys" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
oneshot.workspace = true
|
||||
gpui.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
511
crates/identity/src/lib.rs
Normal file
511
crates/identity/src/lib.rs
Normal file
@@ -0,0 +1,511 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Error};
|
||||
use client_keys::ClientKeys;
|
||||
use common::handle_auth::CoopAuthUrlHandler;
|
||||
use global::{
|
||||
constants::{ACCOUNT_D, NIP17_RELAYS, NIP65_RELAYS, NOSTR_CONNECT_TIMEOUT},
|
||||
shared_state, NostrSignal,
|
||||
};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, red, App, AppContext, Context, Entity, Global, ParentElement,
|
||||
SharedString, Styled, Subscription, Task, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use ui::{
|
||||
input::{InputState, TextInput},
|
||||
notification::Notification,
|
||||
ContextModal, Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
Identity::set_global(cx.new(|cx| Identity::new(window, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalIdentity(Entity<Identity>);
|
||||
|
||||
impl Global for GlobalIdentity {}
|
||||
|
||||
pub struct Identity {
|
||||
profile: Option<Profile>,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Retrieve the Global Identity instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalIdentity>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the Identity instance
|
||||
pub fn get_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalIdentity>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the Global Identity instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalIdentity(state));
|
||||
}
|
||||
|
||||
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let client_keys = ClientKeys::global(cx);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.observe_in(&client_keys, window, |this, state, window, cx| {
|
||||
let auto_login = AppSettings::get_global(cx).settings.auto_login;
|
||||
let has_client_keys = state.read(cx).has_keys();
|
||||
|
||||
// Skip auto login if the user hasn't enabled auto login
|
||||
if has_client_keys && auto_login {
|
||||
this.load(window, cx);
|
||||
} else {
|
||||
this.set_profile(None, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
profile: None,
|
||||
subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
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 secret = event.content;
|
||||
let is_bunker = secret.starts_with("bunker://");
|
||||
|
||||
Ok((secret, is_bunker))
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok((secret, is_bunker)) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.login(&secret, is_bunker, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_profile(None, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn unload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let task = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(ACCOUNT_D)
|
||||
.limit(1);
|
||||
|
||||
// Unset signer
|
||||
shared_state().client.unset_signer().await;
|
||||
|
||||
// Delete account
|
||||
shared_state()
|
||||
.client
|
||||
.database()
|
||||
.delete(filter)
|
||||
.await
|
||||
.is_ok()
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_profile(None, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn login(
|
||||
&mut self,
|
||||
secret: &str,
|
||||
is_bunker: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if is_bunker {
|
||||
if let Ok(uri) = NostrConnectURI::parse(secret) {
|
||||
self.login_with_bunker(uri, window, cx);
|
||||
} else {
|
||||
window.push_notification(Notification::error("Bunker URI is invalid"), cx);
|
||||
self.set_profile(None, cx);
|
||||
}
|
||||
} else if let Ok(enc) = EncryptedSecretKey::from_bech32(secret) {
|
||||
self.login_with_keys(enc, window, cx);
|
||||
} else {
|
||||
window.push_notification(Notification::error("Secret Key is invalid"), cx);
|
||||
self.set_profile(None, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn login_with_bunker(
|
||||
&mut self,
|
||||
uri: NostrConnectURI,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||
let client_keys = ClientKeys::get_global(cx).keys();
|
||||
|
||||
let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else {
|
||||
window.push_notification(Notification::error("Bunker URI is invalid"), cx);
|
||||
self.set_profile(None, cx);
|
||||
return;
|
||||
};
|
||||
// Automatically open auth url
|
||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||
|
||||
let (tx, rx) = oneshot::channel::<Option<NostrConnect>>();
|
||||
|
||||
// Verify the signer, make sure Remote Signer is connected
|
||||
cx.background_spawn(async move {
|
||||
if signer.bunker_uri().await.is_ok() {
|
||||
tx.send(Some(signer)).ok();
|
||||
} else {
|
||||
tx.send(None).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match rx.await {
|
||||
Ok(Some(signer)) => {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(signer, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
_ => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error("Failed to connect to the remote signer"),
|
||||
cx,
|
||||
);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_profile(None, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn login_with_keys(
|
||||
&mut self,
|
||||
enc: EncryptedSecretKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let pwd_input: Entity<InputState> = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||
let weak_input = pwd_input.downgrade();
|
||||
let error: Entity<Option<SharedString>> = cx.new(|_| None);
|
||||
let weak_error = error.downgrade();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let weak_input = weak_input.clone();
|
||||
let weak_error = weak_error.clone();
|
||||
|
||||
this.overlay_closable(false)
|
||||
.show_close(false)
|
||||
.keyboard(false)
|
||||
.confirm()
|
||||
.on_cancel(move |_, _window, cx| {
|
||||
Identity::global(cx).update(cx, |this, cx| {
|
||||
this.set_profile(None, cx);
|
||||
});
|
||||
true
|
||||
})
|
||||
.on_ok(move |_, window, cx| {
|
||||
let value = weak_input
|
||||
.read_with(cx, |state, _cx| state.value().to_string())
|
||||
.ok();
|
||||
|
||||
if let Some(password) = value {
|
||||
if password.is_empty() {
|
||||
weak_error
|
||||
.update(cx, |this, cx| {
|
||||
*this = Some("Password cannot be empty".into());
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
return false;
|
||||
};
|
||||
|
||||
Identity::global(cx).update(cx, |_, cx| {
|
||||
let weak_error = weak_error.clone();
|
||||
let task: Task<Option<SecretKey>> = cx.background_spawn(async move {
|
||||
// Decrypt the password in the background to prevent blocking the main thread
|
||||
enc.decrypt(&password).ok()
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(secret) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_signer(Keys::new(secret), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
weak_error
|
||||
.update(cx, |this, cx| {
|
||||
*this = Some("Invalid password".into());
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
false
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.pt_4()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Password to decrypt your key *")
|
||||
.child(TextInput::new(&pwd_input).small())
|
||||
.when_some(error.read(cx).as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.italic()
|
||||
.text_color(red())
|
||||
.child(error.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Sets a new signer for the client and updates user identity
|
||||
pub fn set_signer<S>(&self, signer: S, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
S: NostrSigner + 'static,
|
||||
{
|
||||
let task: Task<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Update signer
|
||||
shared_state().client.set_signer(signer).await;
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = shared_state()
|
||||
.client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create user's profile with public key and metadata
|
||||
let profile = Profile::new(public_key, metadata);
|
||||
|
||||
// Subscribe for user's data
|
||||
nostr_sdk::async_utility::task::spawn(async move {
|
||||
shared_state().subscribe_for_user_data(public_key).await;
|
||||
});
|
||||
|
||||
// Notify GPUi via the global channel
|
||||
shared_state()
|
||||
.global_sender
|
||||
.send(NostrSignal::SignerUpdated)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| match task.await {
|
||||
Ok(profile) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_profile(Some(profile), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Creates a new identity with the given keys and metadata
|
||||
pub fn new_identity(
|
||||
&mut self,
|
||||
keys: Keys,
|
||||
password: String,
|
||||
metadata: Metadata,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let profile = Profile::new(keys.public_key(), metadata.clone());
|
||||
// Save keys for further use
|
||||
self.write_keys(&keys, password, cx);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Update signer
|
||||
shared_state().client.set_signer(keys).await;
|
||||
// Set metadata
|
||||
shared_state().client.set_metadata(&metadata).await.ok();
|
||||
|
||||
// Create relay list
|
||||
let builder = EventBuilder::new(Kind::RelayList, "").tags(
|
||||
NIP65_RELAYS.into_iter().filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay_metadata(url, None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = shared_state().client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send relay list event: {}", e);
|
||||
};
|
||||
|
||||
// Create messaging relay list
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
|
||||
NIP17_RELAYS.into_iter().filter_map(|url| {
|
||||
if let Ok(url) = RelayUrl::parse(url) {
|
||||
Some(Tag::relay(url))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(e) = shared_state().client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send messaging relay list event: {}", e);
|
||||
};
|
||||
|
||||
// Notify GPUi via the global channel
|
||||
shared_state()
|
||||
.global_sender
|
||||
.send(NostrSignal::SignerUpdated)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Subscribe
|
||||
shared_state()
|
||||
.subscribe_for_user_data(profile.public_key())
|
||||
.await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn write_bunker(&self, uri: &NostrConnectURI, cx: &mut Context<Self>) {
|
||||
let mut value = uri.to_string();
|
||||
|
||||
let Some(public_key) = uri.remote_signer_public_key().cloned() else {
|
||||
log::error!("Remote Signer's public key not found");
|
||||
return;
|
||||
};
|
||||
|
||||
// Remove the secret param if it exists
|
||||
if let Some(secret) = uri.secret() {
|
||||
value = value.replace(secret, "");
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let keys = Keys::generate();
|
||||
let builder = EventBuilder::new(Kind::ApplicationSpecificData, value).tags(vec![
|
||||
Tag::identifier(ACCOUNT_D),
|
||||
Tag::public_key(public_key),
|
||||
]);
|
||||
|
||||
if let Ok(event) = builder.sign(&keys).await {
|
||||
if let Err(e) = shared_state().client.database().save_event(&event).await {
|
||||
log::error!("Failed to save event: {e}");
|
||||
};
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn write_keys(&self, keys: &Keys, password: String, cx: &mut Context<Self>) {
|
||||
let keys = keys.to_owned();
|
||||
let public_key = keys.public_key();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(enc_key) =
|
||||
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)
|
||||
{
|
||||
let keys = Keys::generate();
|
||||
let builder =
|
||||
EventBuilder::new(Kind::ApplicationSpecificData, enc_key.to_bech32().unwrap())
|
||||
.tags(vec![
|
||||
Tag::identifier(ACCOUNT_D),
|
||||
Tag::public_key(public_key),
|
||||
]);
|
||||
|
||||
if let Ok(event) = builder.sign(&keys).await {
|
||||
if let Err(e) = shared_state().client.database().save_event(&event).await {
|
||||
log::error!("Failed to save event: {e}");
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn set_profile(&mut self, profile: Option<Profile>, cx: &mut Context<Self>) {
|
||||
self.profile = profile;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current profile
|
||||
pub fn profile(&self) -> Option<Profile> {
|
||||
self.profile.as_ref().cloned()
|
||||
}
|
||||
|
||||
/// Returns true if a profile is currently loaded
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use global::shared_state;
|
||||
use global::{constants::SETTINGS_D, shared_state};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -7,6 +7,7 @@ use smallvec::{smallvec, SmallVec};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
let state = cx.new(AppSettings::new);
|
||||
|
||||
// Observe for state changes and save settings to database
|
||||
state.update(cx, |this, cx| {
|
||||
this.subscriptions
|
||||
@@ -25,6 +26,7 @@ pub struct Settings {
|
||||
pub hide_user_avatars: bool,
|
||||
pub only_show_trusted: bool,
|
||||
pub backup_messages: bool,
|
||||
pub auto_login: bool,
|
||||
}
|
||||
|
||||
impl AsRef<Settings> for Settings {
|
||||
@@ -40,7 +42,7 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
pub settings: Settings,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
@@ -54,7 +56,7 @@ impl AppSettings {
|
||||
cx.global::<GlobalAppSettings>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the global Settings instance
|
||||
/// Set the Global Settings instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAppSettings(state));
|
||||
}
|
||||
@@ -66,6 +68,7 @@ impl AppSettings {
|
||||
hide_user_avatars: false,
|
||||
only_show_trusted: false,
|
||||
backup_messages: true,
|
||||
auto_login: false,
|
||||
};
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -80,15 +83,11 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> &Settings {
|
||||
self.settings.as_ref()
|
||||
}
|
||||
|
||||
fn get_settings_from_db(&self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn get_settings_from_db(&self, cx: &mut Context<Self>) {
|
||||
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier("coop-settings")
|
||||
.identifier(SETTINGS_D)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = shared_state()
|
||||
@@ -117,19 +116,13 @@ impl AppSettings {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_settings(&self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn set_settings(&self, cx: &mut Context<Self>) {
|
||||
if let Ok(content) = serde_json::to_string(&self.settings) {
|
||||
cx.background_spawn(async move {
|
||||
let Some(identity) = shared_state().identity() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let keys = Keys::generate();
|
||||
let ident = Tag::identifier("coop-settings");
|
||||
|
||||
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||
.tags(vec![ident])
|
||||
.build(identity.public_key())
|
||||
.tags(vec![Tag::identifier(SETTINGS_D)])
|
||||
.sign(&keys)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
div, relative, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
div, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement as _, Styled as _, Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
@@ -65,35 +65,29 @@ impl Selectable for Checkbox {
|
||||
|
||||
impl RenderOnce for Checkbox {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (color, icon_color) = if self.disabled {
|
||||
(cx.theme().ghost_element_disabled, cx.theme().text_muted)
|
||||
let icon_color = if self.disabled {
|
||||
cx.theme().icon_muted
|
||||
} else {
|
||||
(cx.theme().text_accent, cx.theme().surface_background)
|
||||
cx.theme().icon_accent
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.line_height(relative(1.))
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.border_1()
|
||||
.border_color(color)
|
||||
.rounded_sm()
|
||||
.size_4()
|
||||
.flex_shrink_0()
|
||||
.map(|this| match self.checked {
|
||||
false => this.bg(cx.theme().ghost_element_background),
|
||||
_ => this.bg(color),
|
||||
})
|
||||
.relative()
|
||||
.rounded_sm()
|
||||
.size_5()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
svg()
|
||||
.absolute()
|
||||
.top_px()
|
||||
.left_px()
|
||||
.size_3()
|
||||
.top_0p5()
|
||||
.left_0p5()
|
||||
.size_4()
|
||||
.text_color(icon_color)
|
||||
.map(|this| match self.checked {
|
||||
true => this.path(IconName::Check.path()),
|
||||
@@ -108,7 +102,7 @@ impl RenderOnce for Checkbox {
|
||||
.w_full()
|
||||
.overflow_x_hidden()
|
||||
.text_ellipsis()
|
||||
.line_height(relative(1.))
|
||||
.text_sm()
|
||||
.child(label),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
actions::{Cancel, Confirm},
|
||||
animation::cubic_bezier,
|
||||
button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _},
|
||||
h_flex, v_flex, ContextModal, IconName, Root, StyledExt,
|
||||
h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
const CONTEXT: &str = "Modal";
|
||||
@@ -45,7 +45,7 @@ impl Default for ModalButtonProps {
|
||||
ok_text: None,
|
||||
ok_variant: ButtonVariant::Primary,
|
||||
cancel_text: None,
|
||||
cancel_variant: ButtonVariant::default(),
|
||||
cancel_variant: ButtonVariant::Ghost,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ impl Modal {
|
||||
footer: None,
|
||||
content: v_flex(),
|
||||
margin_top: None,
|
||||
width: px(480.),
|
||||
width: px(380.),
|
||||
max_width: None,
|
||||
overlay: true,
|
||||
keyboard: true,
|
||||
@@ -291,12 +291,15 @@ impl RenderOnce for Modal {
|
||||
let render_ok: RenderButtonFn = Box::new({
|
||||
let on_ok = on_ok.clone();
|
||||
let on_close = on_close.clone();
|
||||
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into());
|
||||
let ok_variant = self.button_props.ok_variant;
|
||||
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
|
||||
|
||||
move |_, _| {
|
||||
Button::new("ok")
|
||||
.label(ok_text)
|
||||
.with_variant(ok_variant)
|
||||
.small()
|
||||
.flex_1()
|
||||
.on_click({
|
||||
let on_ok = on_ok.clone();
|
||||
let on_close = on_close.clone();
|
||||
@@ -315,18 +318,22 @@ impl RenderOnce for Modal {
|
||||
.into_any_element()
|
||||
}
|
||||
});
|
||||
|
||||
let render_cancel: RenderButtonFn = Box::new({
|
||||
let on_cancel = on_cancel.clone();
|
||||
let on_close = on_close.clone();
|
||||
let cancel_variant = self.button_props.cancel_variant;
|
||||
let cancel_text = self
|
||||
.button_props
|
||||
.cancel_text
|
||||
.unwrap_or_else(|| "Cancel".into());
|
||||
let cancel_variant = self.button_props.cancel_variant;
|
||||
|
||||
move |_, _| {
|
||||
Button::new("cancel")
|
||||
.label(cancel_text)
|
||||
.with_variant(cancel_variant)
|
||||
.small()
|
||||
.flex_1()
|
||||
.on_click({
|
||||
let on_cancel = on_cancel.clone();
|
||||
let on_close = on_close.clone();
|
||||
@@ -344,15 +351,18 @@ impl RenderOnce for Modal {
|
||||
});
|
||||
|
||||
let window_paddings = crate::window_border::window_paddings(window, cx);
|
||||
|
||||
let view_size = window.viewport_size()
|
||||
- gpui::size(
|
||||
window_paddings.left + window_paddings.right,
|
||||
window_paddings.top + window_paddings.bottom,
|
||||
);
|
||||
|
||||
let bounds = Bounds {
|
||||
origin: Point::default(),
|
||||
size: view_size,
|
||||
};
|
||||
|
||||
let offset_top = px(layer_ix as f32 * 16.);
|
||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||
let x = bounds.center().x - self.width / 2.;
|
||||
@@ -469,12 +479,14 @@ impl RenderOnce for Modal {
|
||||
.when(self.footer.is_some(), |this| {
|
||||
let footer = self.footer.unwrap();
|
||||
|
||||
this.child(h_flex().gap_2().justify_end().children(footer(
|
||||
render_ok,
|
||||
render_cancel,
|
||||
window,
|
||||
cx,
|
||||
)))
|
||||
this.child(
|
||||
h_flex().p_4().gap_1p5().justify_center().children(footer(
|
||||
render_ok,
|
||||
render_cancel,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
})
|
||||
.with_animation(
|
||||
"slide-down",
|
||||
|
||||
Reference in New Issue
Block a user