refactor: Client keys and Identity (#61)

* .

* .

* .

* .

* refactor client keys

* .

* .

* refactor

* .

* .

* .

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

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
global = { path = "../global" }
identity = { path = "../identity" }
settings = { path = "../settings" }
gpui.workspace = true

View File

@@ -9,6 +9,7 @@ use global::shared_state;
use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use room::RoomKind;
@@ -77,7 +78,7 @@ impl ChatRegistry {
let mut subscriptions = smallvec![];
// When the ChatRegistry is created, load all rooms from the local database
subscriptions.push(cx.observe_new::<ChatRegistry>(|this, window, cx| {
subscriptions.push(cx.observe_new::<Self>(|this, window, cx| {
if let Some(window) = window {
this.load_rooms(window, cx);
}
@@ -162,7 +163,7 @@ impl ChatRegistry {
/// 4. Creates Room entities for each unique room
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let client = &shared_state().client;
let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else {
let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else {
return;
};
@@ -288,7 +289,7 @@ impl ChatRegistry {
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let id = room_hash(&event);
let author = event.pubkey;
let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else {
let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else {
return;
};

View File

@@ -7,6 +7,7 @@ use common::profile::RenderProfile;
use common::{compare, room_hash};
use global::shared_state;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use settings::AppSettings;
@@ -165,8 +166,8 @@ impl Room {
/// # Returns
///
/// The Profile of the first member in the room
pub fn first_member(&self, _cx: &App) -> Profile {
let Some(account) = shared_state().identity() else {
pub fn first_member(&self, cx: &App) -> Profile {
let Some(account) = Identity::get_global(cx).profile() else {
return shared_state().person(&self.members[0]);
};
@@ -250,7 +251,7 @@ impl Room {
/// - For a direct message: the other person's avatar
/// - For a group chat: None
pub fn display_image(&self, cx: &App) -> SharedString {
let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
if let Some(picture) = self.picture.as_ref() {
picture.clone()
@@ -550,9 +551,9 @@ impl Room {
&self,
content: &str,
replies: Option<&Vec<Message>>,
_cx: &App,
cx: &App,
) -> Option<Message> {
let author = shared_state().identity()?;
let author = Identity::get_global(cx).profile()?;
let public_key = author.public_key();
let builder = EventBuilder::private_msg_rumor(public_key, content);
@@ -633,7 +634,7 @@ impl Room {
let subject = self.subject.clone();
let picture = self.picture.clone();
let public_keys = Arc::clone(&self.members);
let backup = AppSettings::get_global(cx).settings().backup_messages;
let backup = AppSettings::get_global(cx).settings.backup_messages;
cx.background_spawn(async move {
let signer = shared_state().client.signer().await?;

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" }
gpui.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
@@ -16,4 +17,5 @@ smallvec.workspace = true
smol.workspace = true
futures.workspace = true
webbrowser = "1.0.4"
qrcode-generator = "5.0.0"

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;
pub mod debounced_delay;
pub mod handle_auth;
pub mod profile;
pub async fn nip96_upload(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ edition.workspace = true
publish.workspace = true
[dependencies]
nostr-keyring.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
dirs.workspace = true

View File

@@ -1,9 +1,10 @@
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
pub const KEYRING_PATH: &str = "Coop Safe Storage";
pub const KEYRING_USER_PATH: &str = "coop";
pub const KEYRING_BUNKER: &str = "bunker";
pub const KEYRING_URL: &str = "Coop Safe Storage";
pub const ACCOUNT_D: &str = "coop:account";
pub const SETTINGS_D: &str = "coop:settings";
/// Bootstrap Relays.
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
@@ -12,9 +13,27 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://user.kindpag.es",
"wss://relaydiscovery.com",
];
/// NIP65 Relays. Used for new account
pub const NIP65_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://nos.lol",
];
/// Messaging Relays. Used for new account
pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 300;
/// Unique ID for new message subscription.
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
/// Unique ID for all messages subscription.
@@ -38,10 +57,3 @@ pub const NIP96_SERVER: &str = "https://nostrmedia.com";
pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048;
pub(crate) const BATCH_CHANNEL_LIMIT: usize = 1024;
pub(crate) const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
pub(crate) const NIP65_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://nos.lol",
];

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::mem;
use std::sync::OnceLock;
use std::time::Duration;
use std::{fs, mem};
use anyhow::{anyhow, Error};
use constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT,
METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS,
};
use nostr_keyring::prelude::*;
use nostr_sdk::prelude::*;
use paths::nostr_file;
use smol::lock::RwLock;
use crate::constants::{
BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT, KEYRING_PATH, NIP17_RELAYS, NIP65_RELAYS,
};
use crate::constants::{BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT};
use crate::paths::support_dir;
pub mod constants;
pub mod paths;
@@ -50,11 +40,9 @@ pub enum NostrSignal {
pub struct Globals {
/// The Nostr SDK client
pub client: Client,
/// Cryptographic keys for signing Nostr events
pub client_signer: Keys,
/// Current user's profile information (pubkey and metadata)
pub identity: RwLock<Option<Profile>>,
/// Auto-close options for subscriptions to prevent memory leaks
/// Determines if this is the first time user run Coop
pub first_run: bool,
/// Auto-close options for subscriptions
pub auto_close: Option<SubscribeAutoCloseOptions>,
/// Channel sender for broadcasting global Nostr events to UI
pub global_sender: smol::channel::Sender<NostrSignal>,
@@ -78,18 +66,7 @@ pub fn shared_state() -> &'static Globals {
.install_default()
.ok();
let keyring = NostrKeyring::new(KEYRING_PATH);
// Get the client signer or generate a new one if it doesn't exist
let client_signer = if let Ok(keys) = keyring.get("client") {
keys
} else {
let keys = Keys::generate();
if let Err(e) = keyring.set("client", &keys) {
log::error!("Failed to save client keys: {}", e);
}
keys
};
let first_run = is_first_run().unwrap_or(true);
let opts = Options::new().gossip(true);
let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized");
@@ -101,12 +78,11 @@ pub fn shared_state() -> &'static Globals {
Globals {
client: ClientBuilder::default().database(lmdb).opts(opts).build(),
identity: RwLock::new(None),
persons: RwLock::new(BTreeMap::new()),
auto_close: Some(
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE),
),
client_signer,
first_run,
global_sender,
global_receiver,
batch_sender,
@@ -120,6 +96,7 @@ impl Globals {
pub async fn start(&self) {
self.connect().await;
self.subscribe_for_app_updates().await;
self.preload_metadata().await;
nostr_sdk::async_utility::task::spawn(async move {
let mut batch: BTreeSet<PublicKey> = BTreeSet::new();
@@ -225,112 +202,21 @@ impl Globals {
}
}
/// Sets a new signer for the client and updates user identity
pub async fn set_signer<S>(&self, signer: S) -> Result<(), Error>
where
S: NostrSigner + 'static,
{
let public_key = signer.get_public_key().await?;
// Update signer
self.client.set_signer(signer).await;
// Fetch user's metadata
let metadata = shared_state()
.client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
let profile = Profile::new(public_key, metadata);
let mut identity_guard = self.identity.write().await;
// Update the identity
*identity_guard = Some(profile);
// Subscribe for user's data
nostr_sdk::async_utility::task::spawn(async move {
shared_state().subscribe_for_user_data().await;
});
// Notify GPUi via the global channel
self.global_sender.send(NostrSignal::SignerUpdated).await?;
Ok(())
}
pub async fn unset_signer(&self) {
self.client.reset().await;
if let Ok(signer) = self.client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
let file = support_dir().join(format!(".{}", public_key.to_bech32().unwrap()));
fs::remove_file(&file).ok();
}
}
if let Err(e) = self.global_sender.send(NostrSignal::SignerUnset).await {
log::error!("Failed to send signal to global channel: {}", e);
}
}
/// Creates a new account with the given keys and metadata
pub async fn new_account(&self, keys: Keys, metadata: Metadata) {
let profile = Profile::new(keys.public_key(), metadata.clone());
// Update signer
self.client.set_signer(keys).await;
// Set metadata
self.client.set_metadata(&metadata).await.ok();
// Create relay list
let builder = EventBuilder::new(Kind::RelayList, "").tags(
NIP65_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay_metadata(url, None))
} else {
None
}
}),
);
if let Err(e) = self.client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {}", e);
};
// Create messaging relay list
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(
NIP17_RELAYS.into_iter().filter_map(|url| {
if let Ok(url) = RelayUrl::parse(url) {
Some(Tag::relay(url))
} else {
None
}
}),
);
if let Err(e) = self.client.send_event_builder(builder).await {
log::error!("Failed to send messaging relay list event: {}", e);
};
let mut guard = self.identity.write().await;
// Update the identity
*guard = Some(profile);
// Notify GPUi via the global channel
self.global_sender
.send(NostrSignal::SignerUpdated)
.await
.ok();
// Subscribe
self.subscribe_for_user_data().await;
}
/// Returns the current user's profile (blocking)
pub fn identity(&self) -> Option<Profile> {
self.identity.read_blocking().as_ref().cloned()
}
/// Returns the current user's profile (async)
pub async fn async_identity(&self) -> Option<Profile> {
self.identity.read().await.as_ref().cloned()
}
/// Gets a person's profile from cache or creates default (blocking)
pub fn person(&self, public_key: &PublicKey) -> Profile {
let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) {
@@ -354,7 +240,7 @@ impl Globals {
}
/// Connects to bootstrap and configured relays
async fn connect(&self) {
pub(crate) async fn connect(&self) {
for relay in BOOTSTRAP_RELAYS.into_iter() {
if let Err(e) = self.client.add_relay(relay).await {
log::error!("Failed to add relay {}: {}", relay, e);
@@ -374,13 +260,7 @@ impl Globals {
}
/// Subscribes to user-specific data feeds (DMs, mentions, etc.)
async fn subscribe_for_user_data(&self) {
let Some(profile) = self.identity.read().await.clone() else {
return;
};
let public_key = profile.public_key();
pub async fn subscribe_for_user_data(&self, public_key: PublicKey) {
let metadata = Filter::new()
.kinds(vec![
Kind::Metadata,
@@ -435,7 +315,7 @@ impl Globals {
}
/// Subscribes to application update notifications
async fn subscribe_for_app_updates(&self) {
pub(crate) async fn subscribe_for_app_updates(&self) {
let coordinate = Coordinate {
kind: Kind::Custom(32267),
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
@@ -458,8 +338,22 @@ impl Globals {
log::info!("Subscribing to app updates...");
}
pub(crate) async fn preload_metadata(&self) {
let filter = Filter::new().kind(Kind::Metadata).limit(100);
if let Ok(events) = self.client.database().query(filter).await {
for event in events.into_iter() {
self.insert_person(&event).await;
}
}
}
/// Stores an unwrapped event in local database with reference to original
async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
pub(crate) async fn set_unwrapped(
&self,
root: EventId,
event: &Event,
keys: &Keys,
) -> Result<(), Error> {
// Must be use the random generated keys to sign this event
let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
.tags(vec![Tag::identifier(root), Tag::event(root)])
@@ -473,9 +367,9 @@ impl Globals {
}
/// Retrieves a previously unwrapped event from local database
async fn get_unwrapped(&self, target: EventId) -> Result<Event, Error> {
pub(crate) async fn get_unwrapped(&self, target: EventId) -> Result<Event, Error> {
let filter = Filter::new()
.kind(Kind::Custom(30078))
.kind(Kind::ApplicationSpecificData)
.event(target)
.limit(1);
@@ -487,7 +381,7 @@ impl Globals {
}
/// Unwraps a gift-wrapped event and processes its contents
async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) {
pub(crate) async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) {
let new_messages_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let random_keys = Keys::generate();
@@ -527,7 +421,7 @@ impl Globals {
}
/// Extracts public keys from contact list and queues metadata sync
async fn extract_pubkeys_and_sync(&self, event: &Event) {
pub(crate) async fn extract_pubkeys_and_sync(&self, event: &Event) {
if let Ok(signer) = self.client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
if public_key == event.pubkey {
@@ -539,7 +433,7 @@ impl Globals {
}
/// Fetches metadata for a batch of public keys
async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet<PublicKey>) {
pub(crate) async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet<PublicKey>) {
let kinds = vec![
Kind::Metadata,
Kind::ContactList,
@@ -561,7 +455,7 @@ impl Globals {
}
/// Inserts or updates a person's metadata from a Kind::Metadata event
async fn insert_person(&self, event: &Event) {
pub(crate) async fn insert_person(&self, event: &Event) {
let metadata = Metadata::from_json(&event.content).ok();
self.persons
@@ -577,7 +471,7 @@ impl Globals {
}
/// Notifies UI of application updates via global channel
async fn notify_update(&self, event: &Event) {
pub(crate) async fn notify_update(&self, event: &Event) {
let filter = Filter::new()
.ids(event.tags.event_ids().copied())
.kind(Kind::FileMetadata);
@@ -596,3 +490,14 @@ impl Globals {
}
}
}
fn is_first_run() -> Result<bool, anyhow::Error> {
let flag = support_dir().join(".coop_first_run");
if !flag.exists() {
fs::write(&flag, "")?;
Ok(true) // First run
} else {
Ok(false) // Not first run
}
}

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 global::shared_state;
use global::{constants::SETTINGS_D, shared_state};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
@@ -7,6 +7,7 @@ use smallvec::{smallvec, SmallVec};
pub fn init(cx: &mut App) {
let state = cx.new(AppSettings::new);
// Observe for state changes and save settings to database
state.update(cx, |this, cx| {
this.subscriptions
@@ -25,6 +26,7 @@ pub struct Settings {
pub hide_user_avatars: bool,
pub only_show_trusted: bool,
pub backup_messages: bool,
pub auto_login: bool,
}
impl AsRef<Settings> for Settings {
@@ -40,7 +42,7 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
pub settings: Settings,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
subscriptions: SmallVec<[Subscription; 1]>,
}
impl AppSettings {
@@ -54,7 +56,7 @@ impl AppSettings {
cx.global::<GlobalAppSettings>().0.read(cx)
}
/// Set the global Settings instance
/// Set the Global Settings instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state));
}
@@ -66,6 +68,7 @@ impl AppSettings {
hide_user_avatars: false,
only_show_trusted: false,
backup_messages: true,
auto_login: false,
};
let mut subscriptions = smallvec![];
@@ -80,15 +83,11 @@ impl AppSettings {
}
}
pub fn settings(&self) -> &Settings {
self.settings.as_ref()
}
fn get_settings_from_db(&self, cx: &mut Context<Self>) {
pub(crate) fn get_settings_from_db(&self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier("coop-settings")
.identifier(SETTINGS_D)
.limit(1);
if let Some(event) = shared_state()
@@ -117,19 +116,13 @@ impl AppSettings {
.detach();
}
fn set_settings(&self, cx: &mut Context<Self>) {
pub(crate) fn set_settings(&self, cx: &mut Context<Self>) {
if let Ok(content) = serde_json::to_string(&self.settings) {
cx.background_spawn(async move {
let Some(identity) = shared_state().identity() else {
return;
};
let keys = Keys::generate();
let ident = Tag::identifier("coop-settings");
if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tags(vec![ident])
.build(identity.public_key())
.tags(vec![Tag::identifier(SETTINGS_D)])
.sign(&keys)
.await
{

View File

@@ -1,6 +1,6 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, relative, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
div, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement as _, Styled as _, Window,
};
use theme::ActiveTheme;
@@ -65,35 +65,29 @@ impl Selectable for Checkbox {
impl RenderOnce for Checkbox {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let (color, icon_color) = if self.disabled {
(cx.theme().ghost_element_disabled, cx.theme().text_muted)
let icon_color = if self.disabled {
cx.theme().icon_muted
} else {
(cx.theme().text_accent, cx.theme().surface_background)
cx.theme().icon_accent
};
h_flex()
.id(self.id)
.gap_2()
.items_center()
.line_height(relative(1.))
.child(
v_flex()
.relative()
.border_1()
.border_color(color)
.rounded_sm()
.size_4()
.flex_shrink_0()
.map(|this| match self.checked {
false => this.bg(cx.theme().ghost_element_background),
_ => this.bg(color),
})
.relative()
.rounded_sm()
.size_5()
.bg(cx.theme().elevated_surface_background)
.child(
svg()
.absolute()
.top_px()
.left_px()
.size_3()
.top_0p5()
.left_0p5()
.size_4()
.text_color(icon_color)
.map(|this| match self.checked {
true => this.path(IconName::Check.path()),
@@ -108,7 +102,7 @@ impl RenderOnce for Checkbox {
.w_full()
.overflow_x_hidden()
.text_ellipsis()
.line_height(relative(1.))
.text_sm()
.child(label),
)
} else {

View File

@@ -12,7 +12,7 @@ use crate::{
actions::{Cancel, Confirm},
animation::cubic_bezier,
button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _},
h_flex, v_flex, ContextModal, IconName, Root, StyledExt,
h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt,
};
const CONTEXT: &str = "Modal";
@@ -45,7 +45,7 @@ impl Default for ModalButtonProps {
ok_text: None,
ok_variant: ButtonVariant::Primary,
cancel_text: None,
cancel_variant: ButtonVariant::default(),
cancel_variant: ButtonVariant::Ghost,
}
}
}
@@ -120,7 +120,7 @@ impl Modal {
footer: None,
content: v_flex(),
margin_top: None,
width: px(480.),
width: px(380.),
max_width: None,
overlay: true,
keyboard: true,
@@ -291,12 +291,15 @@ impl RenderOnce for Modal {
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into());
let ok_variant = self.button_props.ok_variant;
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
move |_, _| {
Button::new("ok")
.label(ok_text)
.with_variant(ok_variant)
.small()
.flex_1()
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
@@ -315,18 +318,22 @@ impl RenderOnce for Modal {
.into_any_element()
}
});
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_variant = self.button_props.cancel_variant;
let cancel_text = self
.button_props
.cancel_text
.unwrap_or_else(|| "Cancel".into());
let cancel_variant = self.button_props.cancel_variant;
move |_, _| {
Button::new("cancel")
.label(cancel_text)
.with_variant(cancel_variant)
.small()
.flex_1()
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
@@ -344,15 +351,18 @@ impl RenderOnce for Modal {
});
let window_paddings = crate::window_border::window_paddings(window, cx);
let view_size = window.viewport_size()
- gpui::size(
window_paddings.left + window_paddings.right,
window_paddings.top + window_paddings.bottom,
);
let bounds = Bounds {
origin: Point::default(),
size: view_size,
};
let offset_top = px(layer_ix as f32 * 16.);
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
@@ -469,12 +479,14 @@ impl RenderOnce for Modal {
.when(self.footer.is_some(), |this| {
let footer = self.footer.unwrap();
this.child(h_flex().gap_2().justify_end().children(footer(
render_ok,
render_cancel,
window,
cx,
)))
this.child(
h_flex().p_4().gap_1p5().justify_center().children(footer(
render_ok,
render_cancel,
window,
cx,
)),
)
})
.with_animation(
"slide-down",