feat: Implemented NIP-4e (#11)
* chore: refactor account registry * wip: nip4e * chore: rename account to device * feat: nip44 encryption with master signer * update * refactor * feat: unwrap with device keys * chore: improve handler * chore: fix rustls * chore: refactor onboarding * chore: fix compose * chore: fix send message * chore: fix forgot to request device * fix send message * chore: fix deadlock * chore: small fixes * chore: improve * fix * refactor * refactor * refactor * fix * add fetch request * save keys * fix * update * update * update
This commit is contained in:
68
Cargo.lock
generated
68
Cargo.lock
generated
@@ -2,20 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "account"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
"gpui",
|
||||
"log",
|
||||
"nostr-sdk",
|
||||
"oneshot",
|
||||
"smol",
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.24.2"
|
||||
@@ -941,6 +927,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"common",
|
||||
"global",
|
||||
"gpui",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
@@ -948,7 +935,6 @@ dependencies = [
|
||||
"oneshot",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1144,11 +1130,13 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"global",
|
||||
"gpui",
|
||||
"itertools 0.13.0",
|
||||
"nostr-sdk",
|
||||
"qrcode-generator",
|
||||
"random_name_generator",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1190,12 +1178,12 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
name = "coop"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"account",
|
||||
"anyhow",
|
||||
"chats",
|
||||
"common",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"global",
|
||||
"gpui",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
@@ -1209,7 +1197,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
"tracing-subscriber",
|
||||
"ui",
|
||||
]
|
||||
@@ -2083,6 +2070,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
|
||||
[[package]]
|
||||
name = "global"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"nostr-sdk",
|
||||
"smol",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.15"
|
||||
@@ -3329,7 +3326,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -3353,7 +3350,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-connect"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"nostr",
|
||||
@@ -3365,7 +3362,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-database"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"lru",
|
||||
@@ -3376,7 +3373,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-lmdb"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"heed",
|
||||
@@ -3389,7 +3386,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-relay-pool"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
@@ -3406,7 +3403,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "nostr-sdk"
|
||||
version = "0.39.0"
|
||||
source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d"
|
||||
source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"nostr",
|
||||
@@ -5244,14 +5241,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "state"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"nostr-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -6288,6 +6277,12 @@ dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.100"
|
||||
@@ -6513,6 +6508,17 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d"
|
||||
dependencies = [
|
||||
"redox_syscall",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -11,9 +11,9 @@ gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# Nostr
|
||||
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
|
||||
nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
|
||||
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [
|
||||
"lmdb",
|
||||
"nip96",
|
||||
"nip59",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "account"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smol.workspace = true
|
||||
oneshot.workspace = true
|
||||
log.workspace = true
|
||||
@@ -1 +0,0 @@
|
||||
pub mod registry;
|
||||
@@ -1,148 +0,0 @@
|
||||
use anyhow::{anyhow, Error};
|
||||
use common::{
|
||||
constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID},
|
||||
profile::NostrProfile,
|
||||
};
|
||||
use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
struct GlobalAccount(Entity<Account>);
|
||||
|
||||
impl Global for GlobalAccount {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Account {
|
||||
profile: NostrProfile,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalAccount>()
|
||||
.map(|model| model.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(account: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalAccount(account));
|
||||
}
|
||||
|
||||
pub fn login(signer: Arc<dyn NostrSigner>, cx: &AsyncApp) -> Task<Result<(), anyhow::Error>> {
|
||||
let client = get_client();
|
||||
|
||||
let task: Task<Result<NostrProfile, anyhow::Error>> = cx.background_spawn(async move {
|
||||
// Update nostr signer
|
||||
_ = client.set_signer(signer).await;
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
});
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
cx.update(|cx| {
|
||||
let this = cx.new(|cx| {
|
||||
let this = Self { profile };
|
||||
// Run initial sync data for this account
|
||||
this.sync(cx);
|
||||
this
|
||||
});
|
||||
|
||||
Self::set_global(this, cx)
|
||||
})
|
||||
}
|
||||
Err(e) => Err(anyhow!("Login failed: {}", e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &NostrProfile {
|
||||
&self.profile
|
||||
}
|
||||
|
||||
pub fn verify_inbox_relays(&self, cx: &App) -> Task<Result<Vec<String>, Error>> {
|
||||
let client = get_client();
|
||||
let public_key = self.profile.public_key();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
if let Some(event) = events.first_owned() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
Err(anyhow!("Not found"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sync(&self, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let public_key = self.profile.public_key();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Get contact list
|
||||
let contact_list = Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Err(e) = client.subscribe(contact_list, Some(opts)).await {
|
||||
log::error!("Failed to get contact list: {}", e);
|
||||
}
|
||||
|
||||
// Create a filter to continuously receive new user's data.
|
||||
let data = Filter::new()
|
||||
.kinds(vec![Kind::Metadata, Kind::InboxRelays, Kind::RelayList])
|
||||
.author(public_key)
|
||||
.since(Timestamp::now());
|
||||
|
||||
if let Err(e) = client.subscribe(data, None).await {
|
||||
log::error!("Failed to subscribe to user data: {}", e);
|
||||
}
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(sub_id, filter.clone(), Some(opts))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to subscribe to all messages: {}", e);
|
||||
}
|
||||
|
||||
// Create a filter to continuously receive new messages.
|
||||
let new_filter = filter.limit(0);
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
|
||||
if let Err(e) = client.subscribe_with_id(sub_id, new_filter, None).await {
|
||||
log::error!("Failed to subscribe to new messages: {}", e);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
ui = { path = "../ui" }
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
global = { path = "../global" }
|
||||
chats = { path = "../chats" }
|
||||
account = { path = "../account" }
|
||||
|
||||
gpui.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
|
||||
707
crates/app/src/device.rs
Normal file
707
crates/app/src/device.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as AnyContext, Error};
|
||||
use common::profile::NostrProfile;
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, CLIENT_KEYRING, DEVICE_ANNOUNCEMENT_KIND, DEVICE_REQUEST_KIND,
|
||||
DEVICE_RESPONSE_KIND, MASTER_KEYRING, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_app_name, get_client, get_device_name, set_device_keys,
|
||||
};
|
||||
use gpui::{
|
||||
div, px, relative, App, AppContext, AsyncApp, Context, Entity, Global, ParentElement, Styled,
|
||||
Task, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
indicator::Indicator,
|
||||
notification::Notification,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Root, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::{app, onboarding, relays};
|
||||
|
||||
struct GlobalDevice(Entity<Device>);
|
||||
|
||||
impl Global for GlobalDevice {}
|
||||
|
||||
/// Current Device (Client)
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
#[derive(Debug)]
|
||||
pub struct Device {
|
||||
/// Profile (Metadata) of current user
|
||||
profile: Option<NostrProfile>,
|
||||
/// Client Keys
|
||||
client_keys: Keys,
|
||||
}
|
||||
|
||||
pub fn init(window: &mut Window, cx: &App) {
|
||||
// Initialize client keys
|
||||
let read_keys = cx.read_credentials(CLIENT_KEYRING);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
let client_keys = if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let secret_key = SecretKey::from_slice(&secret).unwrap();
|
||||
|
||||
Keys::new(secret_key)
|
||||
} else {
|
||||
// Generate new keys and save them to keyring
|
||||
let keys = Keys::generate();
|
||||
|
||||
if let Ok(write_keys) = cx.update(|cx| {
|
||||
cx.write_credentials(
|
||||
CLIENT_KEYRING,
|
||||
keys.public_key.to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
_ = write_keys.await;
|
||||
};
|
||||
|
||||
keys
|
||||
};
|
||||
|
||||
cx.update(|cx| {
|
||||
let entity = cx.new(|_| Device {
|
||||
profile: None,
|
||||
client_keys,
|
||||
});
|
||||
|
||||
window_handle
|
||||
.update(cx, |_, window, cx| {
|
||||
// Open the onboarding view
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Observe login behavior
|
||||
window
|
||||
.observe(&entity, cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_login(window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Device::set_global(entity, cx)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn global(cx: &App) -> Option<Entity<Self>> {
|
||||
cx.try_global::<GlobalDevice>().map(|model| model.0.clone())
|
||||
}
|
||||
|
||||
pub fn set_global(device: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalDevice(device));
|
||||
}
|
||||
|
||||
pub fn profile(&self) -> Option<&NostrProfile> {
|
||||
self.profile.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_profile(&mut self, profile: NostrProfile, cx: &mut Context<Self>) {
|
||||
self.profile = Some(profile);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Login and set user signer
|
||||
pub fn login<T>(&self, signer: T, cx: &mut Context<Self>) -> Task<Result<(), Error>>
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
let client = get_client();
|
||||
|
||||
// Set the user's signer as the main signer
|
||||
let login: Task<Result<NostrProfile, Error>> = cx.background_spawn(async move {
|
||||
// Use user's signer for main signer
|
||||
_ = client.set_signer(signer).await;
|
||||
|
||||
// Verify nostr signer and get public key
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
// Fetch user's metadata
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get user's inbox relays
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
let relays = if let Some(event) = client
|
||||
.fetch_events(filter, Duration::from_secs(2))
|
||||
.await?
|
||||
.first_owned()
|
||||
{
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| {
|
||||
if let TagStandard::Relay(url) = t {
|
||||
Some(url.to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<SmallVec<[RelayUrl; 3]>>();
|
||||
|
||||
Some(relays)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let profile = NostrProfile::new(public_key, metadata).relays(relays);
|
||||
|
||||
Ok(profile)
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
match login.await {
|
||||
Ok(user) => {
|
||||
cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.profile = Some(user);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(profile) = self.profile.as_ref() else {
|
||||
// User not logged in, render the Onboarding View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
// Replace the Onboarding View with the Dock View
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(app::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let pubkey = profile.public_key;
|
||||
let client_keys = self.client_keys.clone();
|
||||
|
||||
// User's messaging relays not found
|
||||
if profile.messaging_relays.is_none() {
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_setup_relays(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
// Initialize subscription for current user
|
||||
_ = Device::subscribe(pubkey, &cx).await;
|
||||
|
||||
// Initialize master keys for current user
|
||||
if let Ok(Some(keys)) = Device::fetch_master_keys(pubkey, &cx).await {
|
||||
set_device_keys(keys.clone()).await;
|
||||
|
||||
if let Ok(task) = cx.update(|_, cx| {
|
||||
cx.write_credentials(
|
||||
MASTER_KEYRING,
|
||||
keys.public_key().to_hex().as_str(),
|
||||
keys.secret_key().as_secret_bytes(),
|
||||
)
|
||||
}) {
|
||||
_ = task.await;
|
||||
}
|
||||
|
||||
if let Ok(event) = Device::fetch_request(pubkey, &cx).await {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.handle_request(event, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(pubkey)
|
||||
.since(Timestamp::now());
|
||||
|
||||
_ = client.subscribe(filter, None).await;
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
// Send request for master keys
|
||||
if Device::request_keys(pubkey, client_keys, &cx).await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_waiting_modal(window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Receive device keys approval from other Nostr client,
|
||||
/// then process and update device keys.
|
||||
pub fn handle_response(&self, event: Event, window: &mut Window, cx: &Context<Self>) {
|
||||
let local_signer = self.client_keys.clone().into_nostr_signer();
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
if let Some(public_key) = event.tags.public_keys().copied().last() {
|
||||
let secret = local_signer
|
||||
.nip44_decrypt(&public_key, &event.content)
|
||||
.await?;
|
||||
|
||||
let keys = Keys::parse(&secret)?;
|
||||
|
||||
// Update global state with new device keys
|
||||
set_device_keys(keys).await;
|
||||
log::info!("Received device keys from other client");
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Failed to retrieve device key"))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
if let Err(e) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::success("Device Keys request has been approved"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Received device keys request from other Nostr client,
|
||||
/// then process the request and send approval response.
|
||||
pub fn handle_request(&self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(public_key) = event
|
||||
.tags
|
||||
.find(TagKind::custom("pubkey"))
|
||||
.and_then(|tag| tag.content())
|
||||
.and_then(|content| PublicKey::parse(content).ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = get_client();
|
||||
let read_keys = cx.read_credentials(MASTER_KEYRING);
|
||||
let local_signer = self.client_keys.clone().into_nostr_signer();
|
||||
|
||||
let device_name = event
|
||||
.tags
|
||||
.find(TagKind::Client)
|
||||
.and_then(|tag| tag.content())
|
||||
.unwrap_or("Other Device")
|
||||
.to_owned();
|
||||
|
||||
let response = window.prompt(
|
||||
gpui::PromptLevel::Info,
|
||||
"Requesting Device Keys",
|
||||
Some(
|
||||
format!(
|
||||
"{} is requesting shared device keys stored in this device",
|
||||
device_name
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
&["Approve", "Deny"],
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn_in(window, |_, cx| async move {
|
||||
match response.await {
|
||||
Ok(0) => {
|
||||
if let Ok(Some((_, secret))) = read_keys.await {
|
||||
let local_pubkey = local_signer.get_public_key().await?;
|
||||
|
||||
// Get device's secret key
|
||||
let device_secret = SecretKey::from_slice(&secret)?;
|
||||
|
||||
// Encrypt device's secret key by using NIP-44
|
||||
let content = local_signer
|
||||
.nip44_encrypt(&public_key, &device_secret.to_secret_hex())
|
||||
.await?;
|
||||
|
||||
// Create pubkey tag for other device (lowercase p)
|
||||
let other_tag = Tag::public_key(public_key);
|
||||
|
||||
// Create pubkey tag for this device (uppercase P)
|
||||
let local_tag = Tag::custom(
|
||||
TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)),
|
||||
vec![local_pubkey.to_hex()],
|
||||
);
|
||||
|
||||
// Create event builder
|
||||
let kind = Kind::Custom(DEVICE_RESPONSE_KIND);
|
||||
let tags = vec![other_tag, local_tag];
|
||||
let builder = EventBuilder::new(kind, content).tags(tags);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(err) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys to other client: {}", err);
|
||||
} else {
|
||||
log::info!("Sent device keys to other client");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Device Keys not found"))
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Show setup relays modal
|
||||
///
|
||||
/// NIP-17: <https://github.com/nostr-protocol/nips/blob/master/17.md>
|
||||
pub fn render_setup_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(420.))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&relays, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Show waiting modal
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
pub fn render_waiting_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let msg = format!(
|
||||
"Please open {} and approve sharing device keys request.",
|
||||
get_device_name()
|
||||
);
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(420.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.child("You're using a new device."),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::ELEVEN),
|
||||
)
|
||||
.line_height(relative(1.3))
|
||||
.child(msg),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.footer(
|
||||
div()
|
||||
.p_4()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.w_full()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(Indicator::new().small())
|
||||
.child("Waiting for approval ..."),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch the latest request from the other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
fn fetch_request(user: PublicKey, cx: &AsyncApp) -> Task<Result<Event, Error>> {
|
||||
let client = get_client();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
|
||||
.author(user)
|
||||
.limit(1);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let events = client.fetch_events(filter, Duration::from_secs(2)).await?;
|
||||
|
||||
if let Some(event) = events.first_owned() {
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(anyhow!("No request found"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request to ask for device keys from the other Nostr client
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
fn request_keys(user: PublicKey, client_keys: Keys, cx: &AsyncApp) -> Task<Result<(), Error>> {
|
||||
let client = get_client();
|
||||
let app_name = get_app_name();
|
||||
|
||||
let kind = Kind::Custom(DEVICE_REQUEST_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag = Tag::custom(
|
||||
TagKind::custom("pubkey"),
|
||||
vec![client_keys.public_key().to_hex()],
|
||||
);
|
||||
|
||||
// Create a request event builder
|
||||
let builder = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
log::info!("Sent a request to ask for device keys from the other Nostr client");
|
||||
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
log::error!("Failed to send device keys request: {}", e);
|
||||
}
|
||||
|
||||
log::info!("Waiting for response...");
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_RESPONSE_KIND))
|
||||
.author(user);
|
||||
|
||||
// Getting all previous approvals
|
||||
client.subscribe(filter.clone(), Some(opts)).await?;
|
||||
|
||||
// Continously receive the request approval
|
||||
client
|
||||
.subscribe(filter.since(Timestamp::now()), None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the master keys for current user
|
||||
///
|
||||
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn fetch_master_keys(user: PublicKey, cx: &AsyncApp) -> Task<Result<Option<Keys>, Error>> {
|
||||
let client = get_client();
|
||||
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let filter = Filter::new().kind(kind).author(user).limit(1);
|
||||
|
||||
// Fetch device announcement events
|
||||
let fetch_announcement = cx.background_spawn(async move {
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
println!("event: {:?}", event);
|
||||
Ok(event)
|
||||
} else {
|
||||
Err(anyhow!("Device Announcement not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
let Ok(task) = cx.update(|cx| cx.read_credentials(MASTER_KEYRING)) else {
|
||||
return Err(anyhow!("Failed to read credentials"));
|
||||
};
|
||||
|
||||
let secret = task.await;
|
||||
|
||||
if let Ok(event) = fetch_announcement.await {
|
||||
if let Ok(Some((_, secret))) = secret {
|
||||
let secret_key = SecretKey::from_slice(&secret)?;
|
||||
let keys = Keys::new(secret_key);
|
||||
let device_pubkey = keys.public_key();
|
||||
|
||||
log::info!("Device's Public Key: {:?}", device_pubkey);
|
||||
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Not found")?;
|
||||
let content = n_tag.content().context("Not found")?;
|
||||
let target_pubkey = PublicKey::parse(content)?;
|
||||
|
||||
// If device public key matches announcement public key, re-appoint as master
|
||||
if device_pubkey == target_pubkey {
|
||||
log::info!("Re-appointing this device as master");
|
||||
return Ok(Some(keys));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
} else {
|
||||
log::info!("Device announcement is not found, appoint this device as master");
|
||||
|
||||
let app_name = get_app_name();
|
||||
let keys = Keys::generate();
|
||||
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
let client_tag = Tag::client(app_name);
|
||||
let pubkey_tag =
|
||||
Tag::custom(TagKind::custom("n"), vec![keys.public_key().to_hex()]);
|
||||
|
||||
let _task: Result<(), Error> = cx
|
||||
.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let event = EventBuilder::new(kind, "")
|
||||
.tags(vec![client_tag, pubkey_tag])
|
||||
.sign(&signer)
|
||||
.await?;
|
||||
|
||||
if let Err(e) = client.send_event(&event).await {
|
||||
log::error!("Failed to send device announcement: {}", e);
|
||||
} else {
|
||||
log::info!("Device announcement sent");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(Some(keys))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize subscription for current user
|
||||
fn subscribe(user: PublicKey, cx: &AsyncApp) -> Task<Result<(), Error>> {
|
||||
let client = get_client();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
let device_kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new().kind(device_kind).author(user).limit(1);
|
||||
|
||||
// Create a contact list filter
|
||||
let contacts = Filter::new().kind(Kind::ContactList).author(user).limit(1);
|
||||
|
||||
// Create a user's data filter
|
||||
let data = Filter::new()
|
||||
.author(user)
|
||||
.since(Timestamp::now())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::InboxRelays,
|
||||
Kind::RelayList,
|
||||
device_kind,
|
||||
]);
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
|
||||
|
||||
// Create a filter to continuously receive new messages.
|
||||
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
// Only subscribe to the latest device announcement
|
||||
client.subscribe(device, Some(opts)).await?;
|
||||
|
||||
// Only subscribe to the latest contact list
|
||||
client.subscribe(contacts, Some(opts)).await?;
|
||||
|
||||
// Continuously receive new user's data since now
|
||||
client.subscribe(data, None).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, msg, Some(opts)).await?;
|
||||
|
||||
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
|
||||
client.subscribe_with_id(sub_id, new_msg, None).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
use anyhow::anyhow;
|
||||
use asset::Assets;
|
||||
use chats::registry::ChatRegistry;
|
||||
use common::constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
|
||||
};
|
||||
use device::Device;
|
||||
use futures::{select, FutureExt};
|
||||
use global::{
|
||||
constants::{
|
||||
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND,
|
||||
DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, NEW_MESSAGE_SUB_ID,
|
||||
},
|
||||
get_client, get_device_keys, set_device_name,
|
||||
};
|
||||
use gpui::{
|
||||
actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem,
|
||||
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
|
||||
WindowBounds, WindowKind, WindowOptions,
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -13,32 +19,33 @@ use gpui::{point, SharedString, TitlebarOptions};
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||
use nostr_sdk::{
|
||||
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
|
||||
RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId,
|
||||
nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
|
||||
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
|
||||
};
|
||||
use smol::Timer;
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
|
||||
use ui::{theme::Theme, Root};
|
||||
use views::{app, onboarding};
|
||||
use ui::Root;
|
||||
use views::startup;
|
||||
|
||||
mod asset;
|
||||
mod device;
|
||||
mod views;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
/// Receive event
|
||||
Event(Event),
|
||||
/// Receive request master key event
|
||||
RequestMasterKey(Event),
|
||||
/// Receive approve master key event
|
||||
ReceiveMasterKey(Event),
|
||||
/// Receive EOSE
|
||||
Eose,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Fix crash on startup
|
||||
// TODO: why this is needed?
|
||||
_ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Enable logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
@@ -56,11 +63,17 @@ fn main() {
|
||||
// Connect to default relays
|
||||
app.background_executor()
|
||||
.spawn(async {
|
||||
for relay in BOOTSTRAP_RELAYS.iter() {
|
||||
_ = client.add_relay(*relay).await;
|
||||
// Fix crash on startup
|
||||
// TODO: why this is needed?
|
||||
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
for relay in BOOTSTRAP_RELAYS.into_iter() {
|
||||
_ = client.add_relay(relay).await;
|
||||
}
|
||||
|
||||
_ = client.add_discovery_relay("wss://relaydiscovery.com").await;
|
||||
_ = client.add_discovery_relay("wss://user.kindpag.es").await;
|
||||
|
||||
_ = client.connect().await
|
||||
})
|
||||
.detach();
|
||||
@@ -69,7 +82,7 @@ fn main() {
|
||||
app.background_executor()
|
||||
.spawn(async move {
|
||||
const BATCH_SIZE: usize = 20;
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
@@ -82,7 +95,7 @@ fn main() {
|
||||
Ok(keys) => {
|
||||
batch.extend(keys);
|
||||
if batch.len() >= BATCH_SIZE {
|
||||
sync_metadata(client, mem::take(&mut batch)).await;
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -90,7 +103,7 @@ fn main() {
|
||||
}
|
||||
_ = timeout => {
|
||||
if !batch.is_empty() {
|
||||
sync_metadata(client, mem::take(&mut batch)).await;
|
||||
handle_metadata(mem::take(&mut batch)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,13 +128,12 @@ fn main() {
|
||||
} => {
|
||||
match event.kind {
|
||||
Kind::GiftWrap => {
|
||||
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
|
||||
let mut pubkeys = vec![];
|
||||
|
||||
if let Ok(gift) = handle_gift_wrap(&event).await {
|
||||
// Sign the rumor with the generated keys,
|
||||
// this event will be used for internal only,
|
||||
// and NEVER send to relays.
|
||||
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
|
||||
let mut pubkeys = vec![];
|
||||
pubkeys.extend(event.tags.public_keys());
|
||||
pubkeys.push(event.pubkey);
|
||||
|
||||
@@ -133,23 +145,11 @@ fn main() {
|
||||
}
|
||||
|
||||
// Send all pubkeys to the batch
|
||||
if let Err(e) = batch_tx.send(pubkeys).await {
|
||||
log::error!(
|
||||
"Failed to send pubkeys to batch: {}",
|
||||
e
|
||||
)
|
||||
}
|
||||
_ = batch_tx.send(pubkeys).await;
|
||||
|
||||
// Send this event to the GPUI
|
||||
if new_id == *subscription_id {
|
||||
if let Err(e) =
|
||||
event_tx.send(Signal::Event(event)).await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to send event to GPUI: {}",
|
||||
e
|
||||
)
|
||||
}
|
||||
_ = event_tx.send(Signal::Event(event)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,32 @@ fn main() {
|
||||
let pubkeys =
|
||||
event.tags.public_keys().copied().collect::<HashSet<_>>();
|
||||
|
||||
sync_metadata(client, pubkeys).await;
|
||||
handle_metadata(pubkeys).await;
|
||||
}
|
||||
Kind::Custom(DEVICE_REQUEST_KIND) => {
|
||||
log::info!("Received device keys request");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::RequestMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_RESPONSE_KIND) => {
|
||||
log::info!("Received device keys approval");
|
||||
|
||||
_ = event_tx
|
||||
.send(Signal::ReceiveMasterKey(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
Kind::Custom(DEVICE_ANNOUNCEMENT_KIND) => {
|
||||
log::info!("Device announcement received");
|
||||
|
||||
if let Some(tag) = event
|
||||
.tags
|
||||
.find(TagKind::custom("client"))
|
||||
.and_then(|tag| tag.content())
|
||||
{
|
||||
set_device_name(tag).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -177,62 +202,24 @@ fn main() {
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Handle re-open window
|
||||
app.on_reopen(move |cx| {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let is_login = client.signer().await.is_ok();
|
||||
_ = tx.send(is_login);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
if let Ok(is_login) = rx.await {
|
||||
_ = restore_window(is_login, &mut cx).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
app.run(move |cx| {
|
||||
// Initialize chat global state
|
||||
chats::registry::init(cx);
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
|
||||
// Register the `quit` function
|
||||
cx.on_action(quit);
|
||||
|
||||
// Register the `quit` function with CMD+Q
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
|
||||
// Set menu items
|
||||
cx.set_menus(vec![Menu {
|
||||
name: "Coop".into(),
|
||||
items: vec![MenuItem::action("Quit", Quit)],
|
||||
}]);
|
||||
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn(|cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|cx| {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
match signal {
|
||||
Signal::Eose => chats.update(cx, |this, cx| this.load_chat_rooms(cx)),
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// Set up the window options
|
||||
let window_opts = WindowOptions {
|
||||
let opts = WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
@@ -249,126 +236,103 @@ fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
app_id: Some(APP_ID.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Create a task to read credentials from the keyring service
|
||||
let task = cx.read_credentials(KEYRING_SERVICE);
|
||||
let (tx, rx) = oneshot::channel::<bool>();
|
||||
// Open a window with default options
|
||||
cx.open_window(opts, |window, cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Read credential in OS Keyring
|
||||
cx.background_spawn(async {
|
||||
let is_ready = if let Ok(Some((_, secret))) = task.await {
|
||||
let result = async {
|
||||
let secret_hex = String::from_utf8(secret)?;
|
||||
let keys = Keys::parse(&secret_hex)?;
|
||||
// Initialize chat global state
|
||||
chats::registry::init(cx);
|
||||
|
||||
// Update nostr signer
|
||||
client.set_signer(keys).await;
|
||||
// Initialize device
|
||||
device::init(window, cx);
|
||||
|
||||
Ok::<_, anyhow::Error>(true)
|
||||
}
|
||||
.await;
|
||||
cx.new(|cx| {
|
||||
let root = Root::new(startup::init(window, cx).into(), window, cx);
|
||||
|
||||
result.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Spawn a task to handle events from nostr channel
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
while let Ok(signal) = event_rx.recv().await {
|
||||
cx.update(|window, cx| {
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
|
||||
}
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
chats.update(cx, |this, cx| this.push_message(event, cx))
|
||||
}
|
||||
}
|
||||
Signal::ReceiveMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.handle_response(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Signal::RequestMasterKey(event) => {
|
||||
if let Some(device) = Device::global(cx) {
|
||||
device.update(cx, |this, cx| {
|
||||
this.handle_request(event, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
_ = tx.send(is_ready)
|
||||
root
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
if let Ok(is_ready) = rx.await {
|
||||
if is_ready {
|
||||
// Open a App window
|
||||
cx.open_window(window_opts, |window, cx| {
|
||||
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
} else {
|
||||
// Open a Onboarding window
|
||||
cx.open_window(window_opts, |window, cx| {
|
||||
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
|
||||
})
|
||||
.expect("Failed to open window");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
});
|
||||
}
|
||||
|
||||
async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
|
||||
async fn handle_gift_wrap(gift_wrap: &Event) -> Result<UnwrappedGift, anyhow::Error> {
|
||||
let client = get_client();
|
||||
|
||||
if let Some(device) = get_device_keys().await {
|
||||
// Try to unwrap with the device keys first
|
||||
match UnwrappedGift::from_gift_wrap(&device, gift_wrap).await {
|
||||
Ok(event) => Ok(event),
|
||||
Err(_) => {
|
||||
// Try to unwrap again with the user's signer
|
||||
let signer = client.signer().await?;
|
||||
let event = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?;
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Signer not found"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_metadata(buffer: HashSet<PublicKey>) {
|
||||
let client = get_client();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.idle_timeout(Some(Duration::from_secs(2)));
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(buffer.iter().cloned())
|
||||
.kind(Kind::Metadata)
|
||||
.limit(buffer.len());
|
||||
.limit(buffer.len() * 2)
|
||||
.kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
|
||||
|
||||
if let Err(e) = client.subscribe(filter, Some(opts)).await {
|
||||
log::error!("Failed to sync metadata: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> {
|
||||
let opts = cx
|
||||
.update(|cx| WindowOptions {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(SharedString::new_static(APP_NAME)),
|
||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||
appears_transparent: true,
|
||||
}),
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
|
||||
None,
|
||||
size(px(900.0), px(680.0)),
|
||||
cx,
|
||||
))),
|
||||
#[cfg(target_os = "linux")]
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
#[cfg(target_os = "linux")]
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
kind: WindowKind::Normal,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("Failed to set window options.");
|
||||
|
||||
if is_login {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
|
||||
});
|
||||
} else {
|
||||
_ = cx.open_window(opts, |window, cx| {
|
||||
window.set_window_title(APP_NAME);
|
||||
window.set_app_id(APP_ID);
|
||||
window
|
||||
.observe_window_appearance(|window, cx| {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
|
||||
});
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quit(_: &Quit, cx: &mut App) {
|
||||
log::info!("Gracefully quitting the application . . .");
|
||||
cx.quit();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use account::registry::Account;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
|
||||
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
|
||||
StyledImage, Window,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use state::get_client;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
@@ -15,7 +14,8 @@ use ui::{
|
||||
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
|
||||
};
|
||||
|
||||
use super::{chat, contacts, onboarding, profile, relays::Relays, settings, sidebar, welcome};
|
||||
use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome};
|
||||
use crate::device::Device;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
pub enum PanelKind {
|
||||
@@ -39,6 +39,7 @@ impl AddPanel {
|
||||
|
||||
// Dock actions
|
||||
impl_internal_actions!(dock, [AddPanel]);
|
||||
|
||||
// Account actions
|
||||
actions!(account, [Logout]);
|
||||
|
||||
@@ -47,7 +48,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
|
||||
}
|
||||
|
||||
pub struct AppView {
|
||||
relays: Entity<Option<Vec<String>>>,
|
||||
dock: Entity<DockArea>,
|
||||
}
|
||||
|
||||
@@ -82,56 +82,81 @@ impl AppView {
|
||||
view.set_center(center_panel, window, cx);
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
let relays = cx.new(|_| None);
|
||||
let this = Self { relays, dock };
|
||||
|
||||
// Check user's messaging relays and determine user is ready for NIP17 or not.
|
||||
// If not, show the setup modal and instruct user setup inbox relays
|
||||
this.verify_user_relays(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
cx.new(|_| Self { dock })
|
||||
}
|
||||
|
||||
fn verify_user_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(model) = Account::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let account = model.read(cx);
|
||||
let task = account.verify_inbox_relays(cx);
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if let Ok(relays) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.relays = cx.new(|_| Some(relays));
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this: &mut Self, cx| {
|
||||
this.render_setup_relays(window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(Appearance::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(Appearance::Dark, Some(window), cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = cx.new(|cx| Relays::new(None, window, cx));
|
||||
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.when_some(Device::global(cx), |this, account| {
|
||||
this.when_some(account.read(cx).profile(), |this, profile| {
|
||||
this.child(
|
||||
img(profile.avatar.clone())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
})
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Contacts",
|
||||
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Settings",
|
||||
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
|
||||
)
|
||||
.separator()
|
||||
.menu("Change account", Box::new(Logout))
|
||||
})
|
||||
}
|
||||
|
||||
fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("relays")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Relays)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.render_edit_relays(window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_edit_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = relays::init(window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = relays.read(cx).loading();
|
||||
|
||||
this.keyboard(false)
|
||||
.closable(false)
|
||||
.width(px(420.))
|
||||
.title("Your Messaging Relays are not configured")
|
||||
this.width(px(420.))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(relays.clone())
|
||||
.footer(
|
||||
div()
|
||||
@@ -154,109 +179,6 @@ impl AppView {
|
||||
});
|
||||
}
|
||||
|
||||
fn render_edit_relay(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let relays = self.relays.read(cx).clone();
|
||||
let view = cx.new(|cx| Relays::new(relays, window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, window, cx| {
|
||||
let is_loading = view.read(cx).loading();
|
||||
|
||||
this.width(px(420.))
|
||||
.title("Edit your Messaging Relays")
|
||||
.child(view.clone())
|
||||
.footer(
|
||||
div()
|
||||
.p_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.child(
|
||||
Button::new("update_inbox_relays_btn")
|
||||
.label("Update")
|
||||
.primary()
|
||||
.bold()
|
||||
.rounded(ButtonRounded::Large)
|
||||
.w_full()
|
||||
.loading(is_loading)
|
||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
||||
this.update(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn render_appearance_button(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
Button::new("appearance")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.map(|this| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
this.icon(IconName::Sun)
|
||||
} else {
|
||||
this.icon(IconName::Moon)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
if cx.theme().appearance.is_dark() {
|
||||
Theme::change(Appearance::Light, Some(window), cx);
|
||||
} else {
|
||||
Theme::change(Appearance::Dark, Some(window), cx);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_relays_button(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
Button::new("relays")
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.icon(IconName::Relays)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.render_edit_relay(window, cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_account(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("account")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.reverse()
|
||||
.icon(Icon::new(IconName::ChevronDownSmall))
|
||||
.when_some(Account::global(cx), |this, account| {
|
||||
let profile = account.read(cx).get();
|
||||
|
||||
this.child(
|
||||
img(profile.avatar())
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
.popup_menu(move |this, _, _cx| {
|
||||
this.menu(
|
||||
"Profile",
|
||||
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Contacts",
|
||||
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
|
||||
)
|
||||
.menu(
|
||||
"Settings",
|
||||
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
|
||||
)
|
||||
.separator()
|
||||
.menu("Change account", Box::new(Logout))
|
||||
})
|
||||
}
|
||||
|
||||
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &action.panel {
|
||||
PanelKind::Room(id) => {
|
||||
@@ -303,8 +225,9 @@ impl AppView {
|
||||
})
|
||||
.detach();
|
||||
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(onboarding::init(window, cx).into(), window, cx)
|
||||
Root::update(window, cx, |this, window, cx| {
|
||||
this.replace_view(onboarding::init(window, cx).into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -317,29 +240,37 @@ impl Render for AppView {
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
// Main
|
||||
.child(
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(self.render_appearance_button(window, cx))
|
||||
.child(self.render_relays_button(window, cx))
|
||||
.child(self.render_account(cx)),
|
||||
),
|
||||
TitleBar::new()
|
||||
// Left side
|
||||
.child(div())
|
||||
// Right side
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.child(self.render_mode_btn(cx))
|
||||
.child(self.render_relays_btn(cx))
|
||||
.child(self.render_account_btn(cx)),
|
||||
),
|
||||
)
|
||||
// Dock
|
||||
.child(self.dock.clone()),
|
||||
)
|
||||
.child(self.dock.clone())
|
||||
// Notifications
|
||||
.child(div().absolute().top_8().children(notification_layer))
|
||||
// Modals
|
||||
.children(modal_layer)
|
||||
// Actions
|
||||
.on_action(cx.listener(Self::on_panel_action))
|
||||
.on_action(cx.listener(Self::on_logout_action))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ use anyhow::anyhow;
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
constants::IMAGE_SERVICE,
|
||||
last_seen::LastSeen,
|
||||
profile::NostrProfile,
|
||||
utils::{compare, nip96_upload},
|
||||
};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
|
||||
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
|
||||
@@ -17,7 +17,6 @@ use gpui::{
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use state::get_client;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
@@ -63,8 +62,8 @@ impl ParsedMessage {
|
||||
let created_at = LastSeen(created_at).human_readable();
|
||||
|
||||
Self {
|
||||
avatar: profile.avatar(),
|
||||
display_name: profile.name(),
|
||||
avatar: profile.avatar.clone(),
|
||||
display_name: profile.name.clone(),
|
||||
created_at,
|
||||
content,
|
||||
}
|
||||
@@ -200,7 +199,7 @@ impl Chat {
|
||||
this.room.read_with(cx, |this, _| this.member(&item.0))
|
||||
{
|
||||
this.push_system_message(
|
||||
format!("{} {}", ALERT, member.name()),
|
||||
format!("{} {}", member.name, ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -294,7 +293,7 @@ impl Chat {
|
||||
|
||||
room.members
|
||||
.iter()
|
||||
.find(|m| m.public_key() == ev.pubkey)
|
||||
.find(|m| m.public_key == ev.pubkey)
|
||||
.map(|member| {
|
||||
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
|
||||
})
|
||||
@@ -561,8 +560,11 @@ impl Panel for Chat {
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.room
|
||||
.read_with(cx, |this, _| {
|
||||
let facepill: Vec<SharedString> =
|
||||
this.members.iter().map(|member| member.avatar()).collect();
|
||||
let facepill: Vec<SharedString> = this
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| member.avatar.clone())
|
||||
.collect();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use common::profile::NostrProfile;
|
||||
use global::get_client;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use ui::{
|
||||
button::Button,
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
@@ -141,9 +141,9 @@ impl Render for Contacts {
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.child(img(item.avatar()).size_6()),
|
||||
.child(img(item.avatar).size_6()),
|
||||
)
|
||||
.child(item.name()),
|
||||
.child(item.name),
|
||||
)
|
||||
.hover(|this| {
|
||||
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
mod chat;
|
||||
mod contacts;
|
||||
mod profile;
|
||||
mod relays;
|
||||
mod settings;
|
||||
mod sidebar;
|
||||
mod welcome;
|
||||
|
||||
pub mod app;
|
||||
pub mod onboarding;
|
||||
pub mod relays;
|
||||
pub mod startup;
|
||||
|
||||
@@ -1,185 +1,155 @@
|
||||
use account::registry::Account;
|
||||
use common::qr::create_qr;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div,
|
||||
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window,
|
||||
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Window,
|
||||
};
|
||||
use nostr_connect::prelude::*;
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonCustomVariant, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
notification::NotificationType,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
ContextModal, Root, Size, StyledExt,
|
||||
Disableable, Size, StyledExt,
|
||||
};
|
||||
|
||||
use super::app;
|
||||
use crate::device::Device;
|
||||
|
||||
const LOGO_URL: &str = "brand/coop.svg";
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const SUBTITLE: &str = "A Nostr client for secure communication.";
|
||||
const ALPHA_MESSAGE: &str =
|
||||
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior.";
|
||||
|
||||
const JOIN_URL: &str = "https://start.njump.me/";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
|
||||
Onboarding::new(window, cx)
|
||||
}
|
||||
|
||||
enum PageKind {
|
||||
Bunker,
|
||||
Connect,
|
||||
Selection,
|
||||
}
|
||||
|
||||
pub struct Onboarding {
|
||||
app_keys: Keys,
|
||||
connect_uri: NostrConnectURI,
|
||||
qr_path: Option<PathBuf>,
|
||||
nsec_input: Entity<TextInput>,
|
||||
use_connect: bool,
|
||||
use_privkey: bool,
|
||||
bunker_input: Entity<TextInput>,
|
||||
connect_url: Entity<Option<PathBuf>>,
|
||||
error_message: Entity<Option<SharedString>>,
|
||||
open: PageKind,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: Vec<Subscription>,
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Onboarding {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let app_keys = Keys::generate();
|
||||
let connect_url = cx.new(|_| None);
|
||||
let error_message = cx.new(|_| None);
|
||||
let bunker_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("bunker://<pubkey>?relay=wss://relay.example.com")
|
||||
});
|
||||
|
||||
let connect_uri = NostrConnectURI::client(
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&bunker_input,
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.connect(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
bunker_input,
|
||||
connect_url,
|
||||
error_message,
|
||||
subscriptions,
|
||||
open: PageKind::Selection,
|
||||
is_loading: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn login(&self, signer: NostrConnect, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(device) = Device::global(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entity = cx.weak_entity();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
let login = this.login(signer, cx);
|
||||
|
||||
cx.spawn(|_, cx| async move {
|
||||
if let Err(e) = login.await {
|
||||
cx.update(|cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.set_error(e.to_string(), cx);
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let text = self.bunker_input.read(cx).text().to_string();
|
||||
let keys = Keys::generate();
|
||||
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let Ok(uri) = NostrConnectURI::parse(text) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Bunker URL is invalid".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(signer) = NostrConnect::new(uri, keys, Duration::from_secs(300), None) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.login(signer, window, cx);
|
||||
}
|
||||
|
||||
fn wait_for_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let app_keys = Keys::generate();
|
||||
let url = NostrConnectURI::client(
|
||||
app_keys.public_key(),
|
||||
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
|
||||
"Coop",
|
||||
);
|
||||
|
||||
let nsec_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.placeholder("nsec...")
|
||||
// Create QR code and save it to a app directory
|
||||
let qr_path = create_qr(url.to_string().as_str()).ok();
|
||||
|
||||
// Update QR code path
|
||||
self.connect_url.update(cx, |this, cx| {
|
||||
*this = qr_path;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Save Connect URI as PNG file for display as QR Code
|
||||
let qr_path = create_qr(connect_uri.to_string().as_str()).ok();
|
||||
|
||||
cx.new(|cx| {
|
||||
// Handle Enter event for nsec input
|
||||
let subscriptions = vec![cx.subscribe_in(
|
||||
&nsec_input,
|
||||
window,
|
||||
move |this: &mut Self, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.login_with_private_key(window, cx);
|
||||
}
|
||||
},
|
||||
)];
|
||||
|
||||
Self {
|
||||
app_keys,
|
||||
connect_uri,
|
||||
qr_path,
|
||||
nsec_input,
|
||||
use_connect: false,
|
||||
use_privkey: false,
|
||||
is_loading: false,
|
||||
subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn login_with_nostr_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let uri = self.connect_uri.clone();
|
||||
let app_keys = self.app_keys.clone();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
// Show QR Code for login with Nostr Connect
|
||||
self.use_connect(window, cx);
|
||||
// Open Connect page
|
||||
self.open(PageKind::Connect, window, cx);
|
||||
|
||||
// Wait for connection
|
||||
let (tx, rx) = oneshot::channel::<NostrConnect>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) {
|
||||
tx.send(signer).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(signer) = rx.await {
|
||||
cx.spawn(|mut cx| async move {
|
||||
let signer = Arc::new(signer);
|
||||
|
||||
if Account::login(signer, &cx).await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
} else {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn login_with_private_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.nsec_input.read(cx).text().to_string();
|
||||
let window_handle = window.window_handle();
|
||||
|
||||
if !value.starts_with("nsec") || value.is_empty() {
|
||||
window.push_notification((NotificationType::Warning, "Private Key is required"), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let keys = if let Ok(keys) = Keys::parse(&value) {
|
||||
keys
|
||||
if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
|
||||
self.login(signer, window, cx);
|
||||
} else {
|
||||
window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let signer = Arc::new(keys);
|
||||
|
||||
if Account::login(signer, &cx).await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
window.replace_root(cx, |window, cx| {
|
||||
Root::new(app::init(window, cx).into(), window, cx)
|
||||
});
|
||||
})
|
||||
} else {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn use_connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_connect = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.use_privkey = false;
|
||||
self.use_connect = false;
|
||||
cx.notify();
|
||||
self.set_loading(false, cx);
|
||||
self.set_error("Failed to establish connection".to_owned(), cx);
|
||||
self.open(PageKind::Selection, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
@@ -187,158 +157,31 @@ impl Onboarding {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("login_connect_btn")
|
||||
.label("Login with Nostr Connect")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login_with_nostr_connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("login_privkey_btn")
|
||||
.label("Login with Private Key")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.border(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.hover(cx.theme().base.step(cx, ColorScaleStep::FOUR))
|
||||
.active(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.foreground(cx.theme().base.step(cx, ColorScaleStep::TWELVE)),
|
||||
)
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.use_privkey(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.h_px()
|
||||
.rounded_md()
|
||||
.w_full()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("join_btn")
|
||||
.label("Are you new? Join here!")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(JOIN_URL);
|
||||
}),
|
||||
)
|
||||
fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
|
||||
self.error_message.update(cx, |this, cx| {
|
||||
*this = Some(msg.into());
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Dismiss error message after 3 seconds
|
||||
cx.spawn(|this, cx| async move {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
_ = cx.update(|cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.error_message.update(cx, |this, cx| {
|
||||
*this = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn render_connect_login(&self, cx: &mut Context<Self>) -> Div {
|
||||
let connect_string = self.connect_uri.to_string();
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Scan this QR Code in the Nostr Signer app"),
|
||||
)
|
||||
.child("Recommend: Amber (Android), nsec.app (web),..."),
|
||||
)
|
||||
.when_some(self.qr_path.clone(), |this, path| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_lg()
|
||||
.shadow_lg()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none()
|
||||
.border_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(path).h_64()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.label("Copy Connection String")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(connect_string.clone()))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_privkey_login(&self, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Private Key:")
|
||||
.child(self.nsec_input.clone()),
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Login")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.login_with_private_key(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.reset(window, cx);
|
||||
})),
|
||||
)
|
||||
fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = kind;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,28 +231,182 @@ impl Render for Onboarding {
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_72()
|
||||
.map(|_| match (self.use_privkey, self.use_connect) {
|
||||
(true, _) => self.render_privkey_login(cx),
|
||||
(_, true) => self.render_connect_login(cx),
|
||||
_ => self.render_selection(window, cx),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_2()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(ALPHA_MESSAGE),
|
||||
.child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
|
||||
match self.open {
|
||||
PageKind::Bunker => this
|
||||
.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child("Bunker URL:")
|
||||
.child(self.bunker_input.clone())
|
||||
.when_some(
|
||||
self.error_message.read(cx).as_ref(),
|
||||
|this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.my_1()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Login")
|
||||
.primary()
|
||||
.w_full()
|
||||
.loading(self.is_loading)
|
||||
.disabled(self.is_loading)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.connect(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("use_url")
|
||||
.label("Get Connection URL")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.border(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.hover(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FOUR),
|
||||
)
|
||||
.active(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FIVE),
|
||||
)
|
||||
.foreground(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
)
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.wait_for_connection(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Connect => this
|
||||
.when_some(self.connect_url.read(cx).as_ref(), |this, path| {
|
||||
this.child(
|
||||
div()
|
||||
.mb_2()
|
||||
.p_2()
|
||||
.size_72()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded_lg()
|
||||
.shadow_md()
|
||||
.when(cx.theme().appearance.is_dark(), |this| {
|
||||
this.shadow_none().border_1().border_color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::SIX),
|
||||
)
|
||||
})
|
||||
.bg(cx.theme().background)
|
||||
.child(img(path.as_path()).h_64()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("Scan this QR to connect"),
|
||||
)
|
||||
.child(
|
||||
Button::new("wait_for_connection")
|
||||
.label("Waiting for connection")
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.color(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.border(
|
||||
cx.theme().base.step(cx, ColorScaleStep::THREE),
|
||||
)
|
||||
.hover(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FOUR),
|
||||
)
|
||||
.active(
|
||||
cx.theme().base.step(cx, ColorScaleStep::FIVE),
|
||||
)
|
||||
.foreground(
|
||||
cx.theme()
|
||||
.base
|
||||
.step(cx, ColorScaleStep::TWELVE),
|
||||
),
|
||||
)
|
||||
.w_full()
|
||||
.loading(true)
|
||||
.disabled(true),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.my_2()
|
||||
.w_full()
|
||||
.h_px()
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Selection, window, cx);
|
||||
})),
|
||||
),
|
||||
PageKind::Selection => this
|
||||
.child(
|
||||
Button::new("login_connect_btn")
|
||||
.label("Login with Nostr Connect")
|
||||
.primary()
|
||||
.w_full()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open(PageKind::Bunker, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("join_btn")
|
||||
.label("Are you new? Join here!")
|
||||
.ghost()
|
||||
.w_full()
|
||||
.on_click(|_, _, cx| {
|
||||
cx.open_url(JOIN_URL);
|
||||
}),
|
||||
),
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use async_utility::task::spawn;
|
||||
use common::{constants::IMAGE_SERVICE, utils::nip96_upload};
|
||||
use common::utils::nip96_upload;
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
|
||||
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
|
||||
@@ -7,7 +8,6 @@ use gpui::{
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::fs;
|
||||
use state::get_client;
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use common::constants::NEW_MESSAGE_SUB_ID;
|
||||
use anyhow::{anyhow, Error};
|
||||
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
|
||||
use gpui::{
|
||||
div, prelude::FluentBuilder, px, uniform_list, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, Task, TextAlign, Window,
|
||||
div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
|
||||
Window,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
@@ -12,52 +14,102 @@ use ui::{
|
||||
ContextModal, IconName, Sizable,
|
||||
};
|
||||
|
||||
use crate::device::Device;
|
||||
|
||||
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
|
||||
const HELP_TEXT: &str = "Please add some relays.";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
Relays::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<Url>>,
|
||||
relays: Entity<Vec<RelayUrl>>,
|
||||
input: Entity<TextInput>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Relays {
|
||||
pub fn new(
|
||||
relays: Option<Vec<String>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Self>,
|
||||
) -> Self {
|
||||
let relays = cx.new(|_| {
|
||||
if let Some(value) = relays {
|
||||
value.into_iter().map(|v| Url::parse(&v).unwrap()).collect()
|
||||
} else {
|
||||
vec![
|
||||
Url::parse("wss://auth.nostr1.com").unwrap(),
|
||||
Url::parse("wss://relay.0xchat.com").unwrap(),
|
||||
]
|
||||
}
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let client = get_client();
|
||||
|
||||
let relays = cx.new(|cx| {
|
||||
let relays = vec![
|
||||
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
|
||||
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
|
||||
];
|
||||
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::InboxRelays)
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
let relays = event
|
||||
.tags
|
||||
.filter_standardized(TagKind::Relay)
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Relay(url) => Some(url.to_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(relays)
|
||||
} else {
|
||||
Err(anyhow!("Messaging Relays not found."))
|
||||
}
|
||||
});
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(relays) = task.await {
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
|
||||
this.extend(relays);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
relays
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://...")
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
cx.subscribe_in(&input, window, move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Relays, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
Self {
|
||||
relays,
|
||||
input,
|
||||
subscriptions,
|
||||
is_loading: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
relays,
|
||||
input,
|
||||
is_loading: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -67,7 +119,7 @@ impl Relays {
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<EventId, anyhow::Error>> = cx.background_spawn(async move {
|
||||
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
@@ -123,13 +175,28 @@ impl Relays {
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if task.await.is_ok() {
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
if let Some(device) = Device::global(cx) {
|
||||
let relays = this
|
||||
.read_with(cx, |this, cx| this.relays.read(cx).clone())
|
||||
.unwrap();
|
||||
|
||||
device.update(cx, |this, cx| {
|
||||
if let Some(profile) = this.profile() {
|
||||
let new_profile = profile.clone().relays(Some(relays.into()));
|
||||
this.set_profile(new_profile, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.close_modal(cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -151,7 +218,7 @@ impl Relays {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(&value) {
|
||||
if let Ok(url) = RelayUrl::parse(&value) {
|
||||
self.relays.update(cx, |this, cx| {
|
||||
if !this.contains(&url) {
|
||||
this.push(url);
|
||||
@@ -180,6 +247,7 @@ impl Render for Relays {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
@@ -190,6 +258,7 @@ impl Render for Relays {
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
@@ -197,6 +266,7 @@ impl Render for Relays {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(
|
||||
@@ -264,6 +334,7 @@ impl Render for Relays {
|
||||
items
|
||||
},
|
||||
)
|
||||
.w_full()
|
||||
.min_h(px(120.)),
|
||||
)
|
||||
} else {
|
||||
@@ -274,7 +345,7 @@ impl Render for Relays {
|
||||
.justify_center()
|
||||
.text_xs()
|
||||
.text_align(TextAlign::Center)
|
||||
.child("Please add some relays.")
|
||||
.child(HELP_TEXT)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{profile::NostrProfile, utils::random_name};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
@@ -10,7 +11,6 @@ use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smol::Timer;
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded},
|
||||
@@ -214,7 +214,19 @@ impl Compose {
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
let task: Task<Result<NostrProfile, anyhow::Error>> = if content.starts_with("npub1") {
|
||||
let task: Task<Result<NostrProfile, anyhow::Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let profile = nip05::profile(&content, None).await?;
|
||||
let public_key = profile.public_key;
|
||||
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
})
|
||||
} else {
|
||||
let Ok(public_key) = PublicKey::parse(&content) else {
|
||||
self.set_loading(false, cx);
|
||||
self.set_error(Some("Public Key is not valid".into()), cx);
|
||||
@@ -224,18 +236,8 @@ impl Compose {
|
||||
cx.background_spawn(async move {
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?;
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
})
|
||||
} else {
|
||||
cx.background_spawn(async move {
|
||||
let profile = nip05::profile(&content, None).await?;
|
||||
let public_key = profile.public_key;
|
||||
|
||||
let metadata = client
|
||||
.fetch_metadata(public_key, Duration::from_secs(2))
|
||||
.await?;
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(NostrProfile::new(public_key, metadata))
|
||||
})
|
||||
@@ -244,9 +246,27 @@ impl Compose {
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
match task.await {
|
||||
Ok(profile) => {
|
||||
let public_key = profile.public_key;
|
||||
|
||||
_ = cx
|
||||
.background_spawn(async move {
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
// Create a device announcement filter
|
||||
let device = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(public_key)
|
||||
.limit(1);
|
||||
|
||||
// Only subscribe to the latest device announcement
|
||||
client.subscribe(device, Some(opts)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
_ = cx.update_window(window_handle, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
let public_key = profile.public_key();
|
||||
let public_key = profile.public_key;
|
||||
|
||||
this.contacts.update(cx, |this, cx| {
|
||||
this.insert(0, profile);
|
||||
@@ -432,7 +452,7 @@ impl Render for Compose {
|
||||
|
||||
for ix in range {
|
||||
let item = contacts.get(ix).unwrap().clone();
|
||||
let is_select = selected.contains(&item.public_key());
|
||||
let is_select = selected.contains(&item.public_key);
|
||||
|
||||
items.push(
|
||||
div()
|
||||
@@ -451,10 +471,10 @@ impl Render for Compose {
|
||||
.text_xs()
|
||||
.child(
|
||||
div().flex_shrink_0().child(
|
||||
img(item.avatar()).size_6(),
|
||||
img(item.avatar).size_6(),
|
||||
),
|
||||
)
|
||||
.child(item.name()),
|
||||
.child(item.name),
|
||||
)
|
||||
.when(is_select, |this| {
|
||||
this.child(
|
||||
@@ -475,7 +495,7 @@ impl Render for Compose {
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(SelectContact(
|
||||
item.public_key(),
|
||||
item.public_key,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -117,8 +117,13 @@ impl Sidebar {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img(member.avatar()).size_6().rounded_full().flex_shrink_0())
|
||||
.child(member.name())
|
||||
.child(
|
||||
img(member.avatar.clone())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(member.name.clone())
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -277,12 +282,11 @@ impl Render for Sidebar {
|
||||
.w_full()
|
||||
.when_some(ChatRegistry::global(cx), |this, state| {
|
||||
let is_loading = state.read(cx).is_loading();
|
||||
let rooms = state.read(cx).rooms();
|
||||
let len = rooms.len();
|
||||
let len = state.read(cx).rooms().len();
|
||||
|
||||
if is_loading {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if rooms.is_empty() {
|
||||
} else if state.read(cx).rooms().is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.px_1()
|
||||
@@ -323,7 +327,9 @@ impl Render for Sidebar {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
if let Some(room) = rooms.get(ix) {
|
||||
if let Some(room) =
|
||||
state.read(cx).rooms().get(ix)
|
||||
{
|
||||
items.push(this.render_room(ix, room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
32
crates/app/src/views/startup.rs
Normal file
32
crates/app/src/views/startup.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use gpui::{
|
||||
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
|
||||
};
|
||||
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
|
||||
Startup::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Startup {}
|
||||
|
||||
impl Startup {
|
||||
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self {})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Startup {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
state = { path = "../state" }
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::room::{IncomingEvent, Room};
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use common::{last_seen::LastSeen, utils::room_hash};
|
||||
use global::get_client;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::get_client;
|
||||
use std::{cmp::Reverse, rc::Rc, sync::RwLock};
|
||||
|
||||
use crate::room::{IncomingEvent, Room};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::register(cx);
|
||||
@@ -16,7 +18,7 @@ struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
impl Global for GlobalChatRegistry {}
|
||||
|
||||
pub struct ChatRegistry {
|
||||
rooms: Rc<RwLock<Vec<Entity<Room>>>>,
|
||||
rooms: Vec<Entity<Room>>,
|
||||
is_loading: bool,
|
||||
}
|
||||
|
||||
@@ -28,13 +30,7 @@ impl ChatRegistry {
|
||||
|
||||
pub fn register(cx: &mut App) -> Entity<Self> {
|
||||
Self::global(cx).unwrap_or_else(|| {
|
||||
let entity = cx.new(|cx| {
|
||||
let mut this = Self::new(cx);
|
||||
// Automatically load chat rooms the database when the registry is created
|
||||
this.load_chat_rooms(cx);
|
||||
|
||||
this
|
||||
});
|
||||
let entity = cx.new(Self::new);
|
||||
|
||||
// Set global state
|
||||
cx.set_global(GlobalChatRegistry(entity.clone()));
|
||||
@@ -45,18 +41,13 @@ impl ChatRegistry {
|
||||
|
||||
fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
rooms: Rc::new(RwLock::new(vec![])),
|
||||
rooms: vec![],
|
||||
is_loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
|
||||
self.rooms
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|room| room.read(cx).id)
|
||||
.collect()
|
||||
self.rooms.iter().map(|room| room.read(cx).id).collect()
|
||||
}
|
||||
|
||||
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -90,10 +81,9 @@ impl ChatRegistry {
|
||||
|
||||
cx.spawn(|this, cx| async move {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|cx| {
|
||||
if !events.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
let mut rooms = this.rooms.write().unwrap();
|
||||
_ = cx.update(|cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
if !events.is_empty() {
|
||||
let current_ids = this.current_rooms_ids(cx);
|
||||
let items: Vec<Entity<Room>> = events
|
||||
.into_iter()
|
||||
@@ -108,29 +98,25 @@ impl ChatRegistry {
|
||||
})
|
||||
.collect();
|
||||
|
||||
rooms.extend(items);
|
||||
rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
this.is_loading = false;
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.rooms.extend(items);
|
||||
this.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
this.is_loading = false;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn rooms(&self) -> Vec<Entity<Room>> {
|
||||
self.rooms.read().unwrap().clone()
|
||||
pub fn rooms(&self) -> &[Entity<Room>] {
|
||||
&self.rooms
|
||||
}
|
||||
|
||||
pub fn is_loading(&self) -> bool {
|
||||
@@ -139,8 +125,6 @@ impl ChatRegistry {
|
||||
|
||||
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
||||
self.rooms
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|model| model.read(cx).id == *id)
|
||||
.map(|room| room.downgrade())
|
||||
@@ -151,44 +135,40 @@ impl ChatRegistry {
|
||||
room: Entity<Room>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
|
||||
if !rooms
|
||||
if !self
|
||||
.rooms
|
||||
.iter()
|
||||
.any(|current| current.read(cx) == room.read(cx))
|
||||
{
|
||||
rooms.insert(0, room);
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Room is existed"))
|
||||
Err(anyhow!("Room already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
let id = room_hash(&event);
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
|
||||
if let Some(room) = rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||
room.update(cx, |this, cx| {
|
||||
if let Some(last_seen) = Rc::get_mut(&mut this.last_seen) {
|
||||
*last_seen = LastSeen(event.created_at);
|
||||
}
|
||||
this.last_seen = LastSeen(event.created_at);
|
||||
cx.emit(IncomingEvent { event });
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
// Re sort rooms by last seen
|
||||
rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
|
||||
cx.notify();
|
||||
// Re-sort rooms by last seen
|
||||
self.rooms
|
||||
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
|
||||
} else {
|
||||
let new_room = Room::new(&event, cx);
|
||||
let mut rooms = self.rooms.write().unwrap();
|
||||
|
||||
rooms.insert(0, new_room);
|
||||
cx.notify();
|
||||
// Push the new room to the front of the list
|
||||
self.rooms.insert(0, new_room);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use anyhow::Error;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Context, Error};
|
||||
use common::{last_seen::LastSeen, profile::NostrProfile, utils::room_hash};
|
||||
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys};
|
||||
use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use state::get_client;
|
||||
use std::{collections::HashSet, rc::Rc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingEvent {
|
||||
@@ -13,7 +14,7 @@ pub struct IncomingEvent {
|
||||
|
||||
pub struct Room {
|
||||
pub id: u64,
|
||||
pub last_seen: Rc<LastSeen>,
|
||||
pub last_seen: LastSeen,
|
||||
/// Subject of the room
|
||||
pub name: Option<SharedString>,
|
||||
/// All members of the room
|
||||
@@ -31,7 +32,7 @@ impl PartialEq for Room {
|
||||
impl Room {
|
||||
pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
|
||||
let id = room_hash(event);
|
||||
let last_seen = Rc::new(LastSeen(event.created_at));
|
||||
let last_seen = LastSeen(event.created_at);
|
||||
|
||||
// Get the subject from the event's tags
|
||||
let name = if let Some(tag) = event.tags.find(TagKind::Subject) {
|
||||
@@ -60,7 +61,7 @@ impl Room {
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.name().to_string())
|
||||
.map(|profile| profile.name.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
@@ -94,7 +95,7 @@ impl Room {
|
||||
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
|
||||
self.members
|
||||
.iter()
|
||||
.find(|m| &m.public_key() == public_key)
|
||||
.find(|m| &m.public_key == public_key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ impl Room {
|
||||
|
||||
/// Collect room's member's public keys
|
||||
pub fn public_keys(&self) -> Vec<PublicKey> {
|
||||
self.members.iter().map(|m| m.public_key()).collect()
|
||||
self.members.iter().map(|m| m.public_key).collect()
|
||||
}
|
||||
|
||||
/// Get room's display name
|
||||
@@ -119,8 +120,8 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Get room's last seen
|
||||
pub fn last_seen(&self) -> Rc<LastSeen> {
|
||||
self.last_seen.clone()
|
||||
pub fn last_seen(&self) -> LastSeen {
|
||||
self.last_seen
|
||||
}
|
||||
|
||||
/// Get room's last seen as ago format
|
||||
@@ -158,20 +159,26 @@ impl Room {
|
||||
}
|
||||
|
||||
/// Send message to all room's members
|
||||
///
|
||||
/// NIP-4e: Message will be signed by the device signer
|
||||
pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
|
||||
let client = get_client();
|
||||
let pubkeys = self.public_keys();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let signer = client.signer().await?;
|
||||
let public_key = signer.get_public_key().await?;
|
||||
let Some(device) = get_device_keys().await else {
|
||||
return Err(anyhow!("Device not found. Please restart the application."));
|
||||
};
|
||||
|
||||
let mut msg = Vec::new();
|
||||
let user_signer = client.signer().await?;
|
||||
let user_pubkey = user_signer.get_public_key().await?;
|
||||
|
||||
let mut report = Vec::with_capacity(pubkeys.len());
|
||||
|
||||
let tags: Vec<Tag> = pubkeys
|
||||
.iter()
|
||||
.filter_map(|pubkey| {
|
||||
if pubkey != &public_key {
|
||||
if pubkey != &user_pubkey {
|
||||
Some(Tag::public_key(*pubkey))
|
||||
} else {
|
||||
None
|
||||
@@ -180,17 +187,52 @@ impl Room {
|
||||
.collect();
|
||||
|
||||
for pubkey in pubkeys.iter() {
|
||||
if let Err(e) = client
|
||||
.send_private_msg(*pubkey, &content, tags.clone())
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to send message to {}: {}", pubkey.to_bech32()?, e);
|
||||
// Convert error into string
|
||||
msg.push(e.to_string());
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND))
|
||||
.author(*pubkey)
|
||||
.limit(1);
|
||||
|
||||
// Check if the pubkey has a device announcement,
|
||||
// then choose the appropriate signer based on device presence
|
||||
if let Some(event) = client.database().query(filter).await?.first_owned() {
|
||||
log::info!("Use device signer to send message");
|
||||
let signer = &device;
|
||||
|
||||
// Get the device's public key of other user
|
||||
let n_tag = event.tags.find(TagKind::custom("n")).context("Not found")?;
|
||||
let hex = n_tag.content().context("Not found")?;
|
||||
let target_pubkey = PublicKey::parse(hex)?;
|
||||
|
||||
let rumor = EventBuilder::private_msg_rumor(*pubkey, &content)
|
||||
.tags(tags.clone())
|
||||
.build(user_pubkey);
|
||||
|
||||
let event = EventBuilder::gift_wrap(
|
||||
signer,
|
||||
&target_pubkey,
|
||||
rumor,
|
||||
vec![Tag::public_key(*pubkey)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(e) = client.send_event(&event).await {
|
||||
// Convert error into string, then push it to the report
|
||||
report.push(e.to_string());
|
||||
}
|
||||
} else {
|
||||
log::info!("Use user signer to send message");
|
||||
let signer = &client.signer().await?;
|
||||
|
||||
let event =
|
||||
EventBuilder::private_msg(signer, *pubkey, &content, tags.clone()).await?;
|
||||
|
||||
if let Err(e) = client.send_event(&event).await {
|
||||
report.push(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(msg)
|
||||
Ok(report)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@ edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
global = { path = "../global" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
itertools.workspace = true
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
random_name_generator = "0.3.6"
|
||||
qrcode-generator = "5.0.0"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod last_seen;
|
||||
pub mod profile;
|
||||
pub mod qr;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use global::constants::IMAGE_SERVICE;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::constants::IMAGE_SERVICE;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NostrProfile {
|
||||
public_key: PublicKey,
|
||||
avatar: SharedString,
|
||||
name: SharedString,
|
||||
pub public_key: PublicKey,
|
||||
pub avatar: SharedString,
|
||||
pub name: SharedString,
|
||||
pub messaging_relays: Option<SmallVec<[RelayUrl; 3]>>,
|
||||
}
|
||||
|
||||
impl NostrProfile {
|
||||
@@ -19,20 +20,14 @@ impl NostrProfile {
|
||||
public_key,
|
||||
name,
|
||||
avatar,
|
||||
messaging_relays: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get contact's public key
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
self.avatar.clone()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
/// Set contact's relays
|
||||
pub fn relays(mut self, relays: Option<SmallVec<[RelayUrl; 3]>>) -> Self {
|
||||
self.messaging_relays = relays;
|
||||
self
|
||||
}
|
||||
|
||||
fn extract_avatar(metadata: &Metadata) -> SharedString {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::constants::NIP96_SERVER;
|
||||
use global::constants::NIP96_SERVER;
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use rnglib::{Language, RNG};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "state"
|
||||
name = "global"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
@@ -7,3 +7,6 @@ publish = false
|
||||
[dependencies]
|
||||
nostr-sdk.workspace = true
|
||||
dirs.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
whoami = "1.5.2"
|
||||
@@ -1,7 +1,14 @@
|
||||
pub const KEYRING_SERVICE: &str = "Coop Safe Storage";
|
||||
pub const APP_NAME: &str = "Coop";
|
||||
pub const APP_ID: &str = "su.reya.coop";
|
||||
|
||||
pub const KEYRING: &str = "Coop Safe Storage";
|
||||
pub const CLIENT_KEYRING: &str = "Coop Client Keys";
|
||||
pub const MASTER_KEYRING: &str = "Coop Master Keys";
|
||||
|
||||
pub const DEVICE_ANNOUNCEMENT_KIND: u16 = 10044;
|
||||
pub const DEVICE_REQUEST_KIND: u16 = 4454;
|
||||
pub const DEVICE_RESPONSE_KIND: u16 = 4455;
|
||||
|
||||
/// Bootstrap relays
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
|
||||
"wss://relay.damus.io",
|
||||
104
crates/global/src/lib.rs
Normal file
104
crates/global/src/lib.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use constants::{ALL_MESSAGES_SUB_ID, APP_ID};
|
||||
use dirs::config_dir;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smol::lock::Mutex;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub mod constants;
|
||||
|
||||
/// Nostr Client
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
/// Current App Name
|
||||
static APP_NAME: OnceLock<Arc<str>> = OnceLock::new();
|
||||
/// NIP-4e: Device Keys, used for encryption
|
||||
static DEVICE_KEYS: Mutex<Option<Arc<dyn NostrSigner>>> = Mutex::new(None);
|
||||
/// NIP-4e: Device Name, used for display purposes
|
||||
static DEVICE_NAME: Mutex<Option<Arc<String>>> = Mutex::new(None);
|
||||
|
||||
/// Nostr Client instance
|
||||
pub fn get_client() -> &'static Client {
|
||||
CLIENT.get_or_init(|| {
|
||||
// Setup app data folder
|
||||
let config_dir = config_dir().expect("Config directory not found");
|
||||
let app_dir = config_dir.join(APP_ID);
|
||||
|
||||
// Create app directory if it doesn't exist
|
||||
_ = fs::create_dir_all(&app_dir);
|
||||
|
||||
// Setup database
|
||||
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
|
||||
|
||||
// Client options
|
||||
let opts = Options::new()
|
||||
// NIP-65
|
||||
.gossip(true)
|
||||
// Skip all very slow relays
|
||||
.max_avg_latency(Duration::from_secs(2));
|
||||
|
||||
// Setup Nostr Client
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get app name
|
||||
pub fn get_app_name() -> &'static str {
|
||||
APP_NAME.get_or_init(|| {
|
||||
Arc::from(format!(
|
||||
"Coop on {} ({})",
|
||||
whoami::distro(),
|
||||
whoami::devicename()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get device keys
|
||||
pub async fn get_device_keys() -> Option<Arc<dyn NostrSigner>> {
|
||||
let guard = DEVICE_KEYS.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// Set device keys
|
||||
pub async fn set_device_keys<T>(signer: T)
|
||||
where
|
||||
T: NostrSigner + 'static,
|
||||
{
|
||||
DEVICE_KEYS.lock().await.replace(Arc::new(signer));
|
||||
|
||||
// Re-subscribe to all messages
|
||||
smol::spawn(async move {
|
||||
let client = get_client();
|
||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||
|
||||
if let Ok(signer) = client.signer().await {
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
// Create a filter for getting all gift wrapped events send to current user
|
||||
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
|
||||
|
||||
let id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
|
||||
_ = client.unsubscribe(&id);
|
||||
_ = client.subscribe_with_id(id, filter, Some(opts)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set master's device name
|
||||
pub async fn set_device_name(name: &str) {
|
||||
let mut guard = DEVICE_NAME.lock().await;
|
||||
|
||||
if guard.is_none() {
|
||||
guard.replace(Arc::new(name.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get master's device name
|
||||
pub fn get_device_name() -> Arc<String> {
|
||||
let guard = DEVICE_NAME.lock_blocking();
|
||||
guard.clone().unwrap_or(Arc::new("Main Device".into()))
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use dirs::config_dir;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::{fs, sync::OnceLock, time::Duration};
|
||||
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
pub fn get_client() -> &'static Client {
|
||||
CLIENT.get_or_init(|| {
|
||||
// Setup app data folder
|
||||
let config_dir = config_dir().expect("Config directory not found");
|
||||
let app_dir = config_dir.join("Coop/");
|
||||
|
||||
// Create app directory if it doesn't exist
|
||||
_ = fs::create_dir_all(&app_dir);
|
||||
|
||||
// Setup database
|
||||
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
|
||||
|
||||
// Client options
|
||||
let opts = Options::new()
|
||||
// NIP-65
|
||||
.gossip(true)
|
||||
// Skip all very slow relays
|
||||
.max_avg_latency(Duration::from_millis(800));
|
||||
|
||||
// Setup Nostr Client
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
@@ -47,8 +47,7 @@ impl Modal {
|
||||
.border_1()
|
||||
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
|
||||
.rounded_lg()
|
||||
.shadow_xl()
|
||||
.min_h_48();
|
||||
.shadow_xl();
|
||||
|
||||
Self {
|
||||
base,
|
||||
|
||||
@@ -212,6 +212,11 @@ impl Root {
|
||||
pub fn view(&self) -> &AnyView {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// Replace the root view of the Root.
|
||||
pub fn replace_view(&mut self, view: AnyView) {
|
||||
self.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Root {
|
||||
|
||||
Reference in New Issue
Block a user