refactor: Client keys and Identity (#61)
* . * . * . * . * refactor client keys * . * . * refactor * . * . * . * update new account
This commit is contained in:
70
Cargo.lock
generated
70
Cargo.lock
generated
@@ -925,6 +925,7 @@ dependencies = [
|
|||||||
"fuzzy-matcher",
|
"fuzzy-matcher",
|
||||||
"global",
|
"global",
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"identity",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"log",
|
"log",
|
||||||
"nostr",
|
"nostr",
|
||||||
@@ -971,6 +972,18 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "client_keys"
|
||||||
|
version = "0.1.5"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"global",
|
||||||
|
"gpui",
|
||||||
|
"log",
|
||||||
|
"nostr-sdk",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.54"
|
version = "0.1.54"
|
||||||
@@ -1096,10 +1109,12 @@ dependencies = [
|
|||||||
"global",
|
"global",
|
||||||
"gpui",
|
"gpui",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"qrcode-generator",
|
"qrcode-generator",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"smol",
|
"smol",
|
||||||
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1144,11 +1159,13 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"auto_update",
|
"auto_update",
|
||||||
"chats",
|
"chats",
|
||||||
|
"client_keys",
|
||||||
"common",
|
"common",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures",
|
"futures",
|
||||||
"global",
|
"global",
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"identity",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"log",
|
"log",
|
||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
@@ -1164,7 +1181,6 @@ dependencies = [
|
|||||||
"theme",
|
"theme",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ui",
|
"ui",
|
||||||
"webbrowser",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2189,7 +2205,6 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"log",
|
"log",
|
||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-keyring",
|
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
"smol",
|
"smol",
|
||||||
@@ -2802,6 +2817,24 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identity"
|
||||||
|
version = "0.1.5"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"client_keys",
|
||||||
|
"common",
|
||||||
|
"global",
|
||||||
|
"gpui",
|
||||||
|
"log",
|
||||||
|
"nostr-connect",
|
||||||
|
"nostr-sdk",
|
||||||
|
"oneshot",
|
||||||
|
"settings",
|
||||||
|
"smallvec",
|
||||||
|
"ui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3049,20 +3082,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "keyring"
|
|
||||||
version = "3.6.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"linux-keyutils",
|
|
||||||
"log",
|
|
||||||
"security-framework 2.11.1",
|
|
||||||
"security-framework 3.2.0",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "khronos-egl"
|
name = "khronos-egl"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -3170,16 +3189,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-keyutils"
|
|
||||||
version = "0.2.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.9.1",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -3586,15 +3595,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-keyring"
|
|
||||||
version = "0.42.0"
|
|
||||||
source = "git+https://github.com/rust-nostr/nostr#4096b9da00f18c3089f734b27ce1388616f3cb13"
|
|
||||||
dependencies = [
|
|
||||||
"keyring",
|
|
||||||
"nostr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-lmdb"
|
name = "nostr-lmdb"
|
||||||
version = "0.42.0"
|
version = "0.42.0"
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
|||||||
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
nostr = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "nip96", "nip59", "nip49", "nip44", "nip05"] }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "nip96", "nip59", "nip49", "nip44", "nip05"] }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-keyring = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
emojis = "0.6.4"
|
emojis = "0.6.4"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
global = { path = "../global" }
|
global = { path = "../global" }
|
||||||
|
identity = { path = "../identity" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use global::shared_state;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use room::RoomKind;
|
use room::RoomKind;
|
||||||
@@ -77,7 +78,7 @@ impl ChatRegistry {
|
|||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
// When the ChatRegistry is created, load all rooms from the local database
|
// 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 {
|
if let Some(window) = window {
|
||||||
this.load_rooms(window, cx);
|
this.load_rooms(window, cx);
|
||||||
}
|
}
|
||||||
@@ -162,7 +163,7 @@ impl ChatRegistry {
|
|||||||
/// 4. Creates Room entities for each unique room
|
/// 4. Creates Room entities for each unique room
|
||||||
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let client = &shared_state().client;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,7 +289,7 @@ impl ChatRegistry {
|
|||||||
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let id = room_hash(&event);
|
let id = room_hash(&event);
|
||||||
let author = event.pubkey;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use common::profile::RenderProfile;
|
|||||||
use common::{compare, room_hash};
|
use common::{compare, room_hash};
|
||||||
use global::shared_state;
|
use global::shared_state;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||||
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -165,8 +166,8 @@ impl Room {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The Profile of the first member in the room
|
/// The Profile of the first member in the room
|
||||||
pub fn first_member(&self, _cx: &App) -> Profile {
|
pub fn first_member(&self, cx: &App) -> Profile {
|
||||||
let Some(account) = shared_state().identity() else {
|
let Some(account) = Identity::get_global(cx).profile() else {
|
||||||
return shared_state().person(&self.members[0]);
|
return shared_state().person(&self.members[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ impl Room {
|
|||||||
/// - For a direct message: the other person's avatar
|
/// - For a direct message: the other person's avatar
|
||||||
/// - For a group chat: None
|
/// - For a group chat: None
|
||||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
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() {
|
if let Some(picture) = self.picture.as_ref() {
|
||||||
picture.clone()
|
picture.clone()
|
||||||
@@ -550,9 +551,9 @@ impl Room {
|
|||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
replies: Option<&Vec<Message>>,
|
replies: Option<&Vec<Message>>,
|
||||||
_cx: &App,
|
cx: &App,
|
||||||
) -> Option<Message> {
|
) -> Option<Message> {
|
||||||
let author = shared_state().identity()?;
|
let author = Identity::get_global(cx).profile()?;
|
||||||
let public_key = author.public_key();
|
let public_key = author.public_key();
|
||||||
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
||||||
|
|
||||||
@@ -633,7 +634,7 @@ impl Room {
|
|||||||
let subject = self.subject.clone();
|
let subject = self.subject.clone();
|
||||||
let picture = self.picture.clone();
|
let picture = self.picture.clone();
|
||||||
let public_keys = Arc::clone(&self.members);
|
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 {
|
cx.background_spawn(async move {
|
||||||
let signer = shared_state().client.signer().await?;
|
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" }
|
global = { path = "../global" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
nostr-connect.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
@@ -16,4 +17,5 @@ smallvec.workspace = true
|
|||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|
||||||
|
webbrowser = "1.0.4"
|
||||||
qrcode-generator = "5.0.0"
|
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;
|
use qrcode_generator::QrCodeEcc;
|
||||||
|
|
||||||
pub mod debounced_delay;
|
pub mod debounced_delay;
|
||||||
|
pub mod handle_auth;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub async fn nip96_upload(
|
pub async fn nip96_upload(
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ui = { path = "../ui" }
|
ui = { path = "../ui" }
|
||||||
|
identity = { path = "../identity" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
global = { path = "../global" }
|
global = { path = "../global" }
|
||||||
chats = { path = "../chats" }
|
chats = { path = "../chats" }
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
|
client_keys = { path = "../client_keys" }
|
||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
@@ -35,5 +37,4 @@ smol.workspace = true
|
|||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
oneshot.workspace = true
|
oneshot.workspace = true
|
||||||
|
|
||||||
webbrowser = "1.0.4"
|
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use chats::{ChatRegistry, RoomEmitter};
|
use chats::{ChatRegistry, RoomEmitter};
|
||||||
|
use client_keys::ClientKeys;
|
||||||
use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
|
use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
|
||||||
use global::shared_state;
|
use global::shared_state;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
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,
|
ParentElement, Render, Styled, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
@@ -17,7 +19,8 @@ use ui::button::{Button, ButtonVariants};
|
|||||||
use ui::dock_area::dock::DockPlacement;
|
use ui::dock_area::dock::DockPlacement;
|
||||||
use ui::dock_area::panel::PanelView;
|
use ui::dock_area::panel::PanelView;
|
||||||
use ui::dock_area::{DockArea, DockItem};
|
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::chat::{self, Chat};
|
||||||
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
|
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
|
||||||
@@ -62,7 +65,7 @@ pub struct ChatSpace {
|
|||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
titlebar: bool,
|
titlebar: bool,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
subscriptions: SmallVec<[Subscription; 2]>,
|
subscriptions: SmallVec<[Subscription; 4]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatSpace {
|
impl ChatSpace {
|
||||||
@@ -78,8 +81,81 @@ impl ChatSpace {
|
|||||||
|
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
let chats = ChatRegistry::global(cx);
|
let chats = ChatRegistry::global(cx);
|
||||||
|
let client_keys = ClientKeys::global(cx);
|
||||||
|
let identity = Identity::global(cx);
|
||||||
let mut subscriptions = smallvec![];
|
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
|
// Automatically load messages when chat panel opens
|
||||||
subscriptions.push(cx.observe_new::<Chat>(|this: &mut Chat, window, cx| {
|
subscriptions.push(cx.observe_new::<Chat>(|this: &mut Chat, window, cx| {
|
||||||
if let Some(window) = window {
|
if let Some(window) = window {
|
||||||
@@ -91,7 +167,7 @@ impl ChatSpace {
|
|||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
&chats,
|
&chats,
|
||||||
window,
|
window,
|
||||||
|this: &mut ChatSpace, _state, event, window, cx| {
|
|this: &mut Self, _state, event, window, cx| {
|
||||||
if let RoomEmitter::Open(room) = event {
|
if let RoomEmitter::Open(room) = event {
|
||||||
if let Some(room) = room.upgrade() {
|
if let Some(room) = room.upgrade() {
|
||||||
this.dock.update(cx, |this, cx| {
|
this.dock.update(cx, |this, cx| {
|
||||||
@@ -223,11 +299,10 @@ impl ChatSpace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logout(&self, _window: &mut Window, cx: &mut App) {
|
fn logout(&self, window: &mut Window, cx: &mut App) {
|
||||||
cx.background_spawn(async move {
|
Identity::global(cx).update(cx, |this, cx| {
|
||||||
shared_state().unset_signer().await;
|
this.unload(window, cx);
|
||||||
})
|
});
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
|
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::sync::Arc;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use asset::Assets;
|
use asset::Assets;
|
||||||
use auto_update::AutoUpdater;
|
use auto_update::AutoUpdater;
|
||||||
use chats::ChatRegistry;
|
use chats::ChatRegistry;
|
||||||
|
use global::constants::APP_ID;
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
use global::constants::APP_NAME;
|
use global::constants::APP_NAME;
|
||||||
use global::constants::{APP_ID, KEYRING_BUNKER, KEYRING_USER_PATH};
|
|
||||||
use global::{shared_state, NostrSignal};
|
use global::{shared_state, NostrSignal};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||||
@@ -17,7 +15,6 @@ use gpui::{
|
|||||||
use gpui::{point, SharedString, TitlebarOptions};
|
use gpui::{point, SharedString, TitlebarOptions};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use theme::Theme;
|
use theme::Theme;
|
||||||
use ui::Root;
|
use ui::Root;
|
||||||
|
|
||||||
@@ -94,6 +91,10 @@ fn main() {
|
|||||||
ui::init(cx);
|
ui::init(cx);
|
||||||
// Initialize settings
|
// Initialize settings
|
||||||
settings::init(cx);
|
settings::init(cx);
|
||||||
|
// Initialize client keys
|
||||||
|
client_keys::init(cx);
|
||||||
|
// Initialize identity
|
||||||
|
identity::init(window, cx);
|
||||||
// Initialize auto update
|
// Initialize auto update
|
||||||
auto_update::init(cx);
|
auto_update::init(cx);
|
||||||
// Initialize chat state
|
// Initialize chat state
|
||||||
@@ -102,42 +103,6 @@ fn main() {
|
|||||||
// Initialize chatspace (or workspace)
|
// Initialize chatspace (or workspace)
|
||||||
let chatspace = chatspace::init(window, cx);
|
let chatspace = chatspace::init(window, cx);
|
||||||
let async_chatspace = chatspace.downgrade();
|
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
|
// Spawn a task to handle events from nostr channel
|
||||||
cx.spawn_in(window, async move |_, cx| {
|
cx.spawn_in(window, async move |_, cx| {
|
||||||
@@ -148,14 +113,14 @@ fn main() {
|
|||||||
|
|
||||||
match signal {
|
match signal {
|
||||||
NostrSignal::SignerUpdated => {
|
NostrSignal::SignerUpdated => {
|
||||||
async_chatspace_clone
|
async_chatspace
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
this.open_chats(window, cx);
|
this.open_chats(window, cx);
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
NostrSignal::SignerUnset => {
|
NostrSignal::SignerUnset => {
|
||||||
async_chatspace_clone
|
async_chatspace
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
this.open_onboarding(window, 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) {
|
fn quit(_: &Quit, cx: &mut App) {
|
||||||
log::info!("Gracefully quitting the application . . .");
|
log::info!("Gracefully quitting the application . . .");
|
||||||
cx.quit();
|
cx.quit();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use gpui::{
|
|||||||
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
||||||
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -220,7 +221,7 @@ impl Chat {
|
|||||||
|
|
||||||
// TODO: find a better way to prevent duplicate messages during optimistic updates
|
// TODO: find a better way to prevent duplicate messages during optimistic updates
|
||||||
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool {
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -372,7 +373,7 @@ impl Chat {
|
|||||||
|
|
||||||
self.uploading(true, cx);
|
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 {
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
@@ -546,8 +547,8 @@ impl Chat {
|
|||||||
return div().id(ix);
|
return div().id(ix);
|
||||||
};
|
};
|
||||||
|
|
||||||
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
|
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
||||||
let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars;
|
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
|
||||||
|
|
||||||
let message = message.borrow();
|
let message = message.borrow();
|
||||||
|
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ impl Render for Compose {
|
|||||||
const DESCRIPTION: &str =
|
const DESCRIPTION: &str =
|
||||||
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
|
"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 {
|
let label: SharedString = if self.selected.read(cx).len() > 1 {
|
||||||
"Create Group DM".into()
|
"Create Group DM".into()
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use client_keys::ClientKeys;
|
||||||
|
use common::handle_auth::CoopAuthUrlHandler;
|
||||||
use common::string_to_qr;
|
use common::string_to_qr;
|
||||||
use global::constants::{APP_NAME, KEYRING_BUNKER, KEYRING_USER_PATH};
|
use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
|
||||||
use global::shared_state;
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
|
div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity,
|
||||||
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
|
EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement,
|
||||||
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -20,21 +22,6 @@ use ui::notification::Notification;
|
|||||||
use ui::popup_menu::PopupMenu;
|
use ui::popup_menu::PopupMenu;
|
||||||
use ui::{ContextModal, Disableable, Sizable, StyledExt};
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||||
Login::new(window, cx)
|
Login::new(window, cx)
|
||||||
}
|
}
|
||||||
@@ -72,9 +59,9 @@ impl Login {
|
|||||||
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
|
||||||
//
|
//
|
||||||
// Direct connection initiated by the client
|
// 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 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)
|
NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME)
|
||||||
});
|
});
|
||||||
@@ -90,6 +77,7 @@ impl Login {
|
|||||||
let error = cx.new(|_| None);
|
let error = cx.new(|_| None);
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
|
|
||||||
|
// Subscribe to key input events and process login when the user presses enter
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&key_input, window, |this, _, event, window, cx| {
|
cx.subscribe_in(&key_input, window, |this, _, event, window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
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(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| {
|
cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
if let InputEvent::PressEnter { .. } = event {
|
||||||
@@ -106,41 +95,38 @@ impl Login {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
subscriptions.push(cx.observe_new::<NostrConnectURI>(
|
// Observe the Connect URI that changes when the relay is changed
|
||||||
move |connection_string, _window, cx| {
|
subscriptions.push(cx.observe_new::<NostrConnectURI>(move |uri, _window, cx| {
|
||||||
if let Ok(mut signer) = NostrConnect::new(
|
let client_keys = ClientKeys::get_global(cx).keys();
|
||||||
connection_string.to_owned(),
|
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
|
||||||
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);
|
|
||||||
|
|
||||||
async_active_signer
|
if let Ok(mut signer) = NostrConnect::new(uri.to_owned(), client_keys, timeout, None) {
|
||||||
.update(cx, |this, cx| {
|
// Automatically open auth url
|
||||||
*this = Some(signer);
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the QR Image with the new connection string
|
async_active_signer
|
||||||
async_qr_image
|
|
||||||
.update(cx, |this, cx| {
|
.update(cx, |this, cx| {
|
||||||
*this = string_to_qr(&connection_string.to_string());
|
*this = Some(signer);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})
|
})
|
||||||
.ok();
|
.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(
|
subscriptions.push(cx.observe_in(
|
||||||
&connection_string,
|
&connection_string,
|
||||||
window,
|
window,
|
||||||
|this, entity, _window, cx| {
|
|this, entity, _window, cx| {
|
||||||
let connection_string = entity.read(cx).clone();
|
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
|
// Update the QR Image with the new connection string
|
||||||
this.qr_image.update(cx, |this, cx| {
|
this.qr_image.update(cx, |this, cx| {
|
||||||
@@ -148,58 +134,35 @@ impl Login {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Ok(mut signer) = NostrConnect::new(
|
match NostrConnect::new(
|
||||||
connection_string,
|
connection_string,
|
||||||
client_keys,
|
client_keys,
|
||||||
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
|
||||||
None,
|
None,
|
||||||
) {
|
) {
|
||||||
// Automatically open remote signer's webpage when received auth url
|
Ok(mut signer) => {
|
||||||
signer.auth_url_handler(CoopAuthUrlHandler);
|
// Automatically open auth url
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
|
|
||||||
this.active_signer.update(cx, |this, cx| {
|
this.active_signer.update(cx, |this, cx| {
|
||||||
*this = Some(signer);
|
*this = Some(signer);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::error!("Failed to create Nostr Connect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.observe_in(&active_signer, window, |_this, entity, window, cx| {
|
cx.observe_in(&active_signer, window, |this, entity, window, cx| {
|
||||||
if let Some(signer) = entity.read(cx).clone() {
|
if let Some(mut signer) = entity.read(cx).clone() {
|
||||||
let (tx, rx) = oneshot::channel::<Option<NostrConnectURI>>();
|
// Automatically open auth url
|
||||||
|
signer.auth_url_handler(CoopAuthUrlHandler);
|
||||||
cx.background_spawn(async move {
|
// Wait for connection from remote signer
|
||||||
if let Ok(bunker_uri) = signer.bunker_uri().await {
|
this.wait_for_connection(signer, window, cx);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -222,90 +185,212 @@ impl Login {
|
|||||||
if self.is_logging_in {
|
if self.is_logging_in {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Prevent duplicate login requests
|
||||||
self.set_logging_in(true, cx);
|
self.set_logging_in(true, cx);
|
||||||
|
|
||||||
let client_keys = shared_state().client_signer.clone();
|
// Content can be secret key or bunker://
|
||||||
let content = self.key_input.read(cx).value();
|
match self.key_input.read(cx).value().to_string() {
|
||||||
|
s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx),
|
||||||
if content.starts_with("nsec1") {
|
s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx),
|
||||||
let Ok(keys) = SecretKey::parse(content.as_ref()).map(Keys::new) else {
|
s if s.starts_with("bunker://") => self.login_with_bunker(&s, window, cx),
|
||||||
self.set_error("Secret key is not valid", cx);
|
_ => self.set_error("You must provide a valid Private Key or Bunker.", 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);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>) {
|
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())
|
let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str())
|
||||||
else {
|
else {
|
||||||
@@ -313,7 +398,7 @@ impl Login {
|
|||||||
return;
|
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");
|
let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop");
|
||||||
|
|
||||||
self.connection_string.update(cx, |this, cx| {
|
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>) {
|
fn shutdown_active_signer(&self, cx: &Context<Self>) {
|
||||||
if let Some(signer) = self.active_signer.read(cx).clone() {
|
if let Some(signer) = self.active_signer.read(cx).clone() {
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use async_utility::task::spawn;
|
use async_utility::task::spawn;
|
||||||
use common::nip96_upload;
|
use common::nip96_upload;
|
||||||
use global::constants::KEYRING_USER_PATH;
|
|
||||||
use global::shared_state;
|
use global::shared_state;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -8,6 +7,7 @@ use gpui::{
|
|||||||
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
|
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
|
||||||
Styled, Window,
|
Styled, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smol::fs;
|
use smol::fs;
|
||||||
@@ -16,7 +16,7 @@ use ui::button::{Button, ButtonVariants};
|
|||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
use ui::input::{InputState, TextInput};
|
use ui::input::{InputState, TextInput};
|
||||||
use ui::popup_menu::PopupMenu;
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||||
NewAccount::new(window, cx)
|
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);
|
self.set_submitting(true, cx);
|
||||||
|
|
||||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||||
let name = self.name_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 bio = self.bio_input.read(cx).value().to_string();
|
||||||
|
|
||||||
let keys = Keys::generate();
|
|
||||||
let mut metadata = Metadata::new().display_name(name).about(bio);
|
let mut metadata = Metadata::new().display_name(name).about(bio);
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&avatar) {
|
if let Ok(url) = Url::parse(&avatar) {
|
||||||
metadata = metadata.picture(url);
|
metadata = metadata.picture(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
let save_credential = cx.write_credentials(
|
let current_view = cx.entity().downgrade();
|
||||||
KEYRING_USER_PATH,
|
let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true));
|
||||||
keys.public_key().to_hex().as_str(),
|
let weak_input = pwd_input.downgrade();
|
||||||
keys.secret_key().as_secret_bytes(),
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
window.open_modal(cx, move |this, _window, _cx| {
|
||||||
if let Err(e) = save_credential.await {
|
let metadata = metadata.clone();
|
||||||
log::error!("Failed to save keys: {}", e)
|
let weak_input = weak_input.clone();
|
||||||
};
|
let view_cancel = current_view.clone();
|
||||||
shared_state().new_account(keys, metadata).await;
|
|
||||||
})
|
this.overlay_closable(false)
|
||||||
.detach();
|
.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>) {
|
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 avatar_input = self.avatar_input.downgrade();
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
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::{
|
use gpui::{
|
||||||
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
|
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||||
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
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 theme::ActiveTheme;
|
||||||
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
|
use ui::checkbox::Checkbox;
|
||||||
use ui::dock_area::panel::{Panel, PanelEvent};
|
use ui::dock_area::panel::{Panel, PanelEvent};
|
||||||
|
use ui::indicator::Indicator;
|
||||||
use ui::popup_menu::PopupMenu;
|
use ui::popup_menu::PopupMenu;
|
||||||
use ui::{Icon, IconName, StyledExt};
|
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
|
||||||
|
|
||||||
use crate::chatspace;
|
use crate::chatspace;
|
||||||
|
|
||||||
@@ -16,6 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
|||||||
|
|
||||||
pub struct Onboarding {
|
pub struct Onboarding {
|
||||||
name: SharedString,
|
name: SharedString,
|
||||||
|
local_account: Entity<Option<Profile>>,
|
||||||
|
loading: bool,
|
||||||
closable: bool,
|
closable: bool,
|
||||||
zoomable: bool,
|
zoomable: bool,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -26,14 +41,73 @@ impl Onboarding {
|
|||||||
cx.new(|cx| Self::view(window, cx))
|
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 {
|
Self {
|
||||||
|
local_account,
|
||||||
name: "Onboarding".into(),
|
name: "Onboarding".into(),
|
||||||
|
loading: false,
|
||||||
closable: true,
|
closable: true,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
focus_handle: cx.focus_handle(),
|
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 {
|
impl Panel for Onboarding {
|
||||||
@@ -71,6 +145,9 @@ impl Render for Onboarding {
|
|||||||
const TITLE: &str = "Welcome to Coop!";
|
const TITLE: &str = "Welcome to Coop!";
|
||||||
const SUBTITLE: &str = "Secure Communication on Nostr.";
|
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()
|
div()
|
||||||
.py_4()
|
.py_4()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -78,9 +155,9 @@ impl Render for Onboarding {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.gap_10()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
.mb_10()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.items_center()
|
.items_center()
|
||||||
@@ -104,31 +181,121 @@ impl Render for Onboarding {
|
|||||||
.child(div().text_color(cx.theme().text_muted).child(SUBTITLE)),
|
.child(div().text_color(cx.theme().text_muted).child(SUBTITLE)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.map(|this| {
|
||||||
div()
|
if let Some(profile) = self.local_account.read(cx).as_ref() {
|
||||||
.w_72()
|
this.relative()
|
||||||
.flex()
|
.child(
|
||||||
.flex_col()
|
div()
|
||||||
.gap_2()
|
.id("account")
|
||||||
.child(
|
.mb_3()
|
||||||
Button::new("continue_btn")
|
.h_10()
|
||||||
.icon(Icon::new(IconName::ArrowRight))
|
.w_72()
|
||||||
.label("Start Messaging")
|
.bg(cx.theme().element_background)
|
||||||
.primary()
|
.text_color(cx.theme().element_foreground)
|
||||||
.reverse()
|
.rounded_lg()
|
||||||
.on_click(cx.listener(move |_, _, window, cx| {
|
.text_sm()
|
||||||
chatspace::new_account(window, cx);
|
.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 common::profile::RenderProfile;
|
||||||
use global::{
|
use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER};
|
||||||
constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER},
|
|
||||||
shared_state,
|
|
||||||
};
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context,
|
div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context,
|
||||||
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render,
|
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render,
|
||||||
StatefulInteractiveElement, Styled, Window,
|
StatefulInteractiveElement, Styled, Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::{
|
use ui::{
|
||||||
@@ -33,7 +31,7 @@ impl Preferences {
|
|||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
let media_server = AppSettings::get_global(cx)
|
let media_server = AppSettings::get_global(cx)
|
||||||
.settings()
|
.settings
|
||||||
.media_server
|
.media_server
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ impl Render for Preferences {
|
|||||||
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
|
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
|
||||||
|
|
||||||
let input_state = self.media_input.downgrade();
|
let input_state = self.media_input.downgrade();
|
||||||
let settings = AppSettings::get_global(cx).settings();
|
let settings = AppSettings::get_global(cx).settings.as_ref();
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.track_focus(&self.focus_handle)
|
.track_focus(&self.focus_handle)
|
||||||
@@ -106,7 +104,7 @@ impl Render for Preferences {
|
|||||||
.font_semibold()
|
.font_semibold()
|
||||||
.child("Account"),
|
.child("Account"),
|
||||||
)
|
)
|
||||||
.when_some(shared_state().identity(), |this, profile| {
|
.when_some(Identity::get_global(cx).profile(), |this, profile| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
.w_full()
|
.w_full()
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
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 avatar_input = self.avatar_input.downgrade();
|
||||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ impl DisplayRoom {
|
|||||||
impl RenderOnce for DisplayRoom {
|
impl RenderOnce for DisplayRoom {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let handler = self.handler.clone();
|
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
|
self.base
|
||||||
.id(self.ix)
|
.id(self.ix)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use gpui::{
|
|||||||
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
|
||||||
Window,
|
Window,
|
||||||
};
|
};
|
||||||
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
@@ -70,7 +71,7 @@ impl Sidebar {
|
|||||||
let indicator = cx.new(|_| None);
|
let indicator = cx.new(|_| None);
|
||||||
let local_result = cx.new(|_| None);
|
let local_result = cx.new(|_| None);
|
||||||
let global_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 =
|
let find_input =
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
|
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 {
|
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()
|
div()
|
||||||
.px_3()
|
.px_3()
|
||||||
@@ -491,10 +492,9 @@ impl Render for Sidebar {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
// Account
|
// Account
|
||||||
.when_some(
|
.when_some(Identity::get_global(cx).profile(), |this, profile| {
|
||||||
shared_state().identity.read_blocking().as_ref(),
|
this.child(self.render_account(&profile, cx))
|
||||||
|this, profile| this.child(self.render_account(profile, cx)),
|
})
|
||||||
)
|
|
||||||
// Search Input
|
// Search Input
|
||||||
.child(
|
.child(
|
||||||
div().px_3().w_full().h_7().flex_none().child(
|
div().px_3().w_full().h_7().flex_none().child(
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ impl Render for Startup {
|
|||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.items_center()
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_center()
|
||||||
.gap_6()
|
.gap_6()
|
||||||
.child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
@@ -74,13 +76,10 @@ impl Render for Startup {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
.w_24()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1p5()
|
.justify_center()
|
||||||
.text_xs()
|
|
||||||
.text_center()
|
|
||||||
.text_color(cx.theme().text_muted)
|
|
||||||
.child("Connection in progress")
|
|
||||||
.child(Indicator::new().small()),
|
.child(Indicator::new().small()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nostr-keyring.workspace = true
|
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
pub const APP_NAME: &str = "Coop";
|
pub const APP_NAME: &str = "Coop";
|
||||||
pub const APP_ID: &str = "su.reya.coop";
|
pub const APP_ID: &str = "su.reya.coop";
|
||||||
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
|
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
|
||||||
pub const KEYRING_PATH: &str = "Coop Safe Storage";
|
pub const KEYRING_URL: &str = "Coop Safe Storage";
|
||||||
pub const KEYRING_USER_PATH: &str = "coop";
|
|
||||||
pub const KEYRING_BUNKER: &str = "bunker";
|
pub const ACCOUNT_D: &str = "coop:account";
|
||||||
|
pub const SETTINGS_D: &str = "coop:settings";
|
||||||
|
|
||||||
/// Bootstrap Relays.
|
/// Bootstrap Relays.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||||
@@ -12,9 +13,27 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
|||||||
"wss://user.kindpag.es",
|
"wss://user.kindpag.es",
|
||||||
"wss://relaydiscovery.com",
|
"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.
|
/// Search Relays.
|
||||||
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
|
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.
|
/// Unique ID for new message subscription.
|
||||||
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
|
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
|
||||||
/// Unique ID for all messages subscription.
|
/// 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 GLOBAL_CHANNEL_LIMIT: usize = 2048;
|
||||||
pub(crate) const BATCH_CHANNEL_LIMIT: usize = 1024;
|
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::collections::{BTreeMap, BTreeSet};
|
||||||
use std::mem;
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::{fs, mem};
|
||||||
|
|
||||||
use anyhow::{anyhow, Error};
|
use anyhow::{anyhow, Error};
|
||||||
use constants::{
|
use constants::{
|
||||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
|
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
|
||||||
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
|
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
|
||||||
};
|
};
|
||||||
use nostr_keyring::prelude::*;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use paths::nostr_file;
|
use paths::nostr_file;
|
||||||
use smol::lock::RwLock;
|
use smol::lock::RwLock;
|
||||||
|
|
||||||
use crate::constants::{
|
use crate::constants::{BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT};
|
||||||
BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT, KEYRING_PATH, NIP17_RELAYS, NIP65_RELAYS,
|
use crate::paths::support_dir;
|
||||||
};
|
|
||||||
|
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
@@ -50,11 +40,9 @@ pub enum NostrSignal {
|
|||||||
pub struct Globals {
|
pub struct Globals {
|
||||||
/// The Nostr SDK client
|
/// The Nostr SDK client
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
/// Cryptographic keys for signing Nostr events
|
/// Determines if this is the first time user run Coop
|
||||||
pub client_signer: Keys,
|
pub first_run: bool,
|
||||||
/// Current user's profile information (pubkey and metadata)
|
/// Auto-close options for subscriptions
|
||||||
pub identity: RwLock<Option<Profile>>,
|
|
||||||
/// Auto-close options for subscriptions to prevent memory leaks
|
|
||||||
pub auto_close: Option<SubscribeAutoCloseOptions>,
|
pub auto_close: Option<SubscribeAutoCloseOptions>,
|
||||||
/// Channel sender for broadcasting global Nostr events to UI
|
/// Channel sender for broadcasting global Nostr events to UI
|
||||||
pub global_sender: smol::channel::Sender<NostrSignal>,
|
pub global_sender: smol::channel::Sender<NostrSignal>,
|
||||||
@@ -78,18 +66,7 @@ pub fn shared_state() -> &'static Globals {
|
|||||||
.install_default()
|
.install_default()
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let keyring = NostrKeyring::new(KEYRING_PATH);
|
let first_run = is_first_run().unwrap_or(true);
|
||||||
// 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 opts = Options::new().gossip(true);
|
let opts = Options::new().gossip(true);
|
||||||
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
|
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
|
||||||
|
|
||||||
@@ -101,12 +78,11 @@ pub fn shared_state() -> &'static Globals {
|
|||||||
|
|
||||||
Globals {
|
Globals {
|
||||||
client: ClientBuilder::default().database(lmdb).opts(opts).build(),
|
client: ClientBuilder::default().database(lmdb).opts(opts).build(),
|
||||||
identity: RwLock::new(None),
|
|
||||||
persons: RwLock::new(BTreeMap::new()),
|
persons: RwLock::new(BTreeMap::new()),
|
||||||
auto_close: Some(
|
auto_close: Some(
|
||||||
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
|
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
|
||||||
),
|
),
|
||||||
client_signer,
|
first_run,
|
||||||
global_sender,
|
global_sender,
|
||||||
global_receiver,
|
global_receiver,
|
||||||
batch_sender,
|
batch_sender,
|
||||||
@@ -120,6 +96,7 @@ impl Globals {
|
|||||||
pub async fn start(&self) {
|
pub async fn start(&self) {
|
||||||
self.connect().await;
|
self.connect().await;
|
||||||
self.subscribe_for_app_updates().await;
|
self.subscribe_for_app_updates().await;
|
||||||
|
self.preload_metadata().await;
|
||||||
|
|
||||||
nostr_sdk::async_utility::task::spawn(async move {
|
nostr_sdk::async_utility::task::spawn(async move {
|
||||||
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
|
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) {
|
pub async fn unset_signer(&self) {
|
||||||
self.client.reset().await;
|
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 {
|
if let Err(e) = self.global_sender.send(NostrSignal::SignerUnset).await {
|
||||||
log::error!("Failed to send signal to global channel: {}", e);
|
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)
|
/// Gets a person's profile from cache or creates default (blocking)
|
||||||
pub fn person(&self, public_key: &PublicKey) -> Profile {
|
pub fn person(&self, public_key: &PublicKey) -> Profile {
|
||||||
let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) {
|
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
|
/// Connects to bootstrap and configured relays
|
||||||
async fn connect(&self) {
|
pub(crate) async fn connect(&self) {
|
||||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||||
if let Err(e) = self.client.add_relay(relay).await {
|
if let Err(e) = self.client.add_relay(relay).await {
|
||||||
log::error!("Failed to add relay {}: {}", relay, e);
|
log::error!("Failed to add relay {}: {}", relay, e);
|
||||||
@@ -374,13 +260,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribes to user-specific data feeds (DMs, mentions, etc.)
|
/// Subscribes to user-specific data feeds (DMs, mentions, etc.)
|
||||||
async fn subscribe_for_user_data(&self) {
|
pub async fn subscribe_for_user_data(&self, public_key: PublicKey) {
|
||||||
let Some(profile) = self.identity.read().await.clone() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let public_key = profile.public_key();
|
|
||||||
|
|
||||||
let metadata = Filter::new()
|
let metadata = Filter::new()
|
||||||
.kinds(vec![
|
.kinds(vec![
|
||||||
Kind::Metadata,
|
Kind::Metadata,
|
||||||
@@ -435,7 +315,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribes to application update notifications
|
/// Subscribes to application update notifications
|
||||||
async fn subscribe_for_app_updates(&self) {
|
pub(crate) async fn subscribe_for_app_updates(&self) {
|
||||||
let coordinate = Coordinate {
|
let coordinate = Coordinate {
|
||||||
kind: Kind::Custom(32267),
|
kind: Kind::Custom(32267),
|
||||||
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
|
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
|
||||||
@@ -458,8 +338,22 @@ impl Globals {
|
|||||||
log::info!("Subscribing to app updates...");
|
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
|
/// 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
|
// Must be use the random generated keys to sign this event
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||||
.tags(vec![Tag::identifier(root), Tag::event(root)])
|
.tags(vec![Tag::identifier(root), Tag::event(root)])
|
||||||
@@ -473,9 +367,9 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves a previously unwrapped event from local database
|
/// 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()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Custom(30078))
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.event(target)
|
.event(target)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -487,7 +381,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Unwraps a gift-wrapped event and processes its contents
|
/// 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 new_messages_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||||
let random_keys = Keys::generate();
|
let random_keys = Keys::generate();
|
||||||
|
|
||||||
@@ -527,7 +421,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts public keys from contact list and queues metadata sync
|
/// 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(signer) = self.client.signer().await {
|
||||||
if let Ok(public_key) = signer.get_public_key().await {
|
if let Ok(public_key) = signer.get_public_key().await {
|
||||||
if public_key == event.pubkey {
|
if public_key == event.pubkey {
|
||||||
@@ -539,7 +433,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches metadata for a batch of public keys
|
/// 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![
|
let kinds = vec![
|
||||||
Kind::Metadata,
|
Kind::Metadata,
|
||||||
Kind::ContactList,
|
Kind::ContactList,
|
||||||
@@ -561,7 +455,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts or updates a person's metadata from a Kind::Metadata event
|
/// 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();
|
let metadata = Metadata::from_json(&event.content).ok();
|
||||||
|
|
||||||
self.persons
|
self.persons
|
||||||
@@ -577,7 +471,7 @@ impl Globals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Notifies UI of application updates via global channel
|
/// 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()
|
let filter = Filter::new()
|
||||||
.ids(event.tags.event_ids().copied())
|
.ids(event.tags.event_ids().copied())
|
||||||
.kind(Kind::FileMetadata);
|
.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 anyhow::anyhow;
|
||||||
use global::shared_state;
|
use global::{constants::SETTINGS_D, shared_state};
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -7,6 +7,7 @@ use smallvec::{smallvec, SmallVec};
|
|||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
pub fn init(cx: &mut App) {
|
||||||
let state = cx.new(AppSettings::new);
|
let state = cx.new(AppSettings::new);
|
||||||
|
|
||||||
// Observe for state changes and save settings to database
|
// Observe for state changes and save settings to database
|
||||||
state.update(cx, |this, cx| {
|
state.update(cx, |this, cx| {
|
||||||
this.subscriptions
|
this.subscriptions
|
||||||
@@ -25,6 +26,7 @@ pub struct Settings {
|
|||||||
pub hide_user_avatars: bool,
|
pub hide_user_avatars: bool,
|
||||||
pub only_show_trusted: bool,
|
pub only_show_trusted: bool,
|
||||||
pub backup_messages: bool,
|
pub backup_messages: bool,
|
||||||
|
pub auto_login: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<Settings> for Settings {
|
impl AsRef<Settings> for Settings {
|
||||||
@@ -40,7 +42,7 @@ impl Global for GlobalAppSettings {}
|
|||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
subscriptions: SmallVec<[Subscription; 2]>,
|
subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppSettings {
|
impl AppSettings {
|
||||||
@@ -54,7 +56,7 @@ impl AppSettings {
|
|||||||
cx.global::<GlobalAppSettings>().0.read(cx)
|
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) {
|
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalAppSettings(state));
|
cx.set_global(GlobalAppSettings(state));
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,7 @@ impl AppSettings {
|
|||||||
hide_user_avatars: false,
|
hide_user_avatars: false,
|
||||||
only_show_trusted: false,
|
only_show_trusted: false,
|
||||||
backup_messages: true,
|
backup_messages: true,
|
||||||
|
auto_login: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut subscriptions = smallvec![];
|
let mut subscriptions = smallvec![];
|
||||||
@@ -80,15 +83,11 @@ impl AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn settings(&self) -> &Settings {
|
pub(crate) fn get_settings_from_db(&self, cx: &mut Context<Self>) {
|
||||||
self.settings.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_settings_from_db(&self, cx: &mut Context<Self>) {
|
|
||||||
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
|
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::ApplicationSpecificData)
|
.kind(Kind::ApplicationSpecificData)
|
||||||
.identifier("coop-settings")
|
.identifier(SETTINGS_D)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Some(event) = shared_state()
|
if let Some(event) = shared_state()
|
||||||
@@ -117,19 +116,13 @@ impl AppSettings {
|
|||||||
.detach();
|
.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) {
|
if let Ok(content) = serde_json::to_string(&self.settings) {
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let Some(identity) = shared_state().identity() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let ident = Tag::identifier("coop-settings");
|
|
||||||
|
|
||||||
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
|
||||||
.tags(vec![ident])
|
.tags(vec![Tag::identifier(SETTINGS_D)])
|
||||||
.build(identity.public_key())
|
|
||||||
.sign(&keys)
|
.sign(&keys)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
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,
|
SharedString, StatefulInteractiveElement as _, Styled as _, Window,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
@@ -65,35 +65,29 @@ impl Selectable for Checkbox {
|
|||||||
|
|
||||||
impl RenderOnce for Checkbox {
|
impl RenderOnce for Checkbox {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let (color, icon_color) = if self.disabled {
|
let icon_color = if self.disabled {
|
||||||
(cx.theme().ghost_element_disabled, cx.theme().text_muted)
|
cx.theme().icon_muted
|
||||||
} else {
|
} else {
|
||||||
(cx.theme().text_accent, cx.theme().surface_background)
|
cx.theme().icon_accent
|
||||||
};
|
};
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_center()
|
.items_center()
|
||||||
.line_height(relative(1.))
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.relative()
|
|
||||||
.border_1()
|
|
||||||
.border_color(color)
|
|
||||||
.rounded_sm()
|
|
||||||
.size_4()
|
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.map(|this| match self.checked {
|
.relative()
|
||||||
false => this.bg(cx.theme().ghost_element_background),
|
.rounded_sm()
|
||||||
_ => this.bg(color),
|
.size_5()
|
||||||
})
|
.bg(cx.theme().elevated_surface_background)
|
||||||
.child(
|
.child(
|
||||||
svg()
|
svg()
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_px()
|
.top_0p5()
|
||||||
.left_px()
|
.left_0p5()
|
||||||
.size_3()
|
.size_4()
|
||||||
.text_color(icon_color)
|
.text_color(icon_color)
|
||||||
.map(|this| match self.checked {
|
.map(|this| match self.checked {
|
||||||
true => this.path(IconName::Check.path()),
|
true => this.path(IconName::Check.path()),
|
||||||
@@ -108,7 +102,7 @@ impl RenderOnce for Checkbox {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.overflow_x_hidden()
|
.overflow_x_hidden()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.line_height(relative(1.))
|
.text_sm()
|
||||||
.child(label),
|
.child(label),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
actions::{Cancel, Confirm},
|
actions::{Cancel, Confirm},
|
||||||
animation::cubic_bezier,
|
animation::cubic_bezier,
|
||||||
button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _},
|
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";
|
const CONTEXT: &str = "Modal";
|
||||||
@@ -45,7 +45,7 @@ impl Default for ModalButtonProps {
|
|||||||
ok_text: None,
|
ok_text: None,
|
||||||
ok_variant: ButtonVariant::Primary,
|
ok_variant: ButtonVariant::Primary,
|
||||||
cancel_text: None,
|
cancel_text: None,
|
||||||
cancel_variant: ButtonVariant::default(),
|
cancel_variant: ButtonVariant::Ghost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ impl Modal {
|
|||||||
footer: None,
|
footer: None,
|
||||||
content: v_flex(),
|
content: v_flex(),
|
||||||
margin_top: None,
|
margin_top: None,
|
||||||
width: px(480.),
|
width: px(380.),
|
||||||
max_width: None,
|
max_width: None,
|
||||||
overlay: true,
|
overlay: true,
|
||||||
keyboard: true,
|
keyboard: true,
|
||||||
@@ -291,12 +291,15 @@ impl RenderOnce for Modal {
|
|||||||
let render_ok: RenderButtonFn = Box::new({
|
let render_ok: RenderButtonFn = Box::new({
|
||||||
let on_ok = on_ok.clone();
|
let on_ok = on_ok.clone();
|
||||||
let on_close = on_close.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_variant = self.button_props.ok_variant;
|
||||||
|
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
|
||||||
|
|
||||||
move |_, _| {
|
move |_, _| {
|
||||||
Button::new("ok")
|
Button::new("ok")
|
||||||
.label(ok_text)
|
.label(ok_text)
|
||||||
.with_variant(ok_variant)
|
.with_variant(ok_variant)
|
||||||
|
.small()
|
||||||
|
.flex_1()
|
||||||
.on_click({
|
.on_click({
|
||||||
let on_ok = on_ok.clone();
|
let on_ok = on_ok.clone();
|
||||||
let on_close = on_close.clone();
|
let on_close = on_close.clone();
|
||||||
@@ -315,18 +318,22 @@ impl RenderOnce for Modal {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let render_cancel: RenderButtonFn = Box::new({
|
let render_cancel: RenderButtonFn = Box::new({
|
||||||
let on_cancel = on_cancel.clone();
|
let on_cancel = on_cancel.clone();
|
||||||
let on_close = on_close.clone();
|
let on_close = on_close.clone();
|
||||||
|
let cancel_variant = self.button_props.cancel_variant;
|
||||||
let cancel_text = self
|
let cancel_text = self
|
||||||
.button_props
|
.button_props
|
||||||
.cancel_text
|
.cancel_text
|
||||||
.unwrap_or_else(|| "Cancel".into());
|
.unwrap_or_else(|| "Cancel".into());
|
||||||
let cancel_variant = self.button_props.cancel_variant;
|
|
||||||
move |_, _| {
|
move |_, _| {
|
||||||
Button::new("cancel")
|
Button::new("cancel")
|
||||||
.label(cancel_text)
|
.label(cancel_text)
|
||||||
.with_variant(cancel_variant)
|
.with_variant(cancel_variant)
|
||||||
|
.small()
|
||||||
|
.flex_1()
|
||||||
.on_click({
|
.on_click({
|
||||||
let on_cancel = on_cancel.clone();
|
let on_cancel = on_cancel.clone();
|
||||||
let on_close = on_close.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 window_paddings = crate::window_border::window_paddings(window, cx);
|
||||||
|
|
||||||
let view_size = window.viewport_size()
|
let view_size = window.viewport_size()
|
||||||
- gpui::size(
|
- gpui::size(
|
||||||
window_paddings.left + window_paddings.right,
|
window_paddings.left + window_paddings.right,
|
||||||
window_paddings.top + window_paddings.bottom,
|
window_paddings.top + window_paddings.bottom,
|
||||||
);
|
);
|
||||||
|
|
||||||
let bounds = Bounds {
|
let bounds = Bounds {
|
||||||
origin: Point::default(),
|
origin: Point::default(),
|
||||||
size: view_size,
|
size: view_size,
|
||||||
};
|
};
|
||||||
|
|
||||||
let offset_top = px(layer_ix as f32 * 16.);
|
let offset_top = px(layer_ix as f32 * 16.);
|
||||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||||
let x = bounds.center().x - self.width / 2.;
|
let x = bounds.center().x - self.width / 2.;
|
||||||
@@ -469,12 +479,14 @@ impl RenderOnce for Modal {
|
|||||||
.when(self.footer.is_some(), |this| {
|
.when(self.footer.is_some(), |this| {
|
||||||
let footer = self.footer.unwrap();
|
let footer = self.footer.unwrap();
|
||||||
|
|
||||||
this.child(h_flex().gap_2().justify_end().children(footer(
|
this.child(
|
||||||
render_ok,
|
h_flex().p_4().gap_1p5().justify_center().children(footer(
|
||||||
render_cancel,
|
render_ok,
|
||||||
window,
|
render_cancel,
|
||||||
cx,
|
window,
|
||||||
)))
|
cx,
|
||||||
|
)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.with_animation(
|
.with_animation(
|
||||||
"slide-down",
|
"slide-down",
|
||||||
|
|||||||
Reference in New Issue
Block a user