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:
reya
2025-03-08 19:29:25 +07:00
committed by GitHub
parent 81664e3d4e
commit a53b2181ab
31 changed files with 1744 additions and 1065 deletions

707
crates/app/src/device.rs Normal file
View 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(())
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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