refactor: Client keys and Identity (#61)

* .

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

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

70
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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;
}; };

View File

@@ -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?;

View 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

View 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()
}
}

View File

@@ -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"

View 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(())
})
}
}

View File

@@ -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(

View File

@@ -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"] }

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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();

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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,

View File

@@ -1,12 +1,25 @@
use anyhow::anyhow;
use common::profile::RenderProfile;
use global::constants::ACCOUNT_D;
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{ 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);
})),
),
)
} }
} }

View File

@@ -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()

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()),
), ),
) )

View File

@@ -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

View File

@@ -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",
];

View File

@@ -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
}
}

View 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
View 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()
}
}

View File

@@ -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
{ {

View File

@@ -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 {

View File

@@ -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",