feat: Out-of-Box Experience (#12)

* refactor app view

* feat: onboarding

* add back buttons in onboarding
This commit is contained in:
reya
2025-03-25 12:34:39 +07:00
committed by GitHub
parent e15cbcc22c
commit 00cf7792e5
34 changed files with 1680 additions and 1920 deletions

17
crates/account/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "account"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
ui = { path = "../ui" }
common = { path = "../common" }
global = { path = "../global" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smol.workspace = true
smallvec.workspace = true
log.workspace = true

166
crates/account/src/lib.rs Normal file
View File

@@ -0,0 +1,166 @@
use std::time::Duration;
use anyhow::Error;
use common::profile::NostrProfile;
use global::{
constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID},
get_client,
};
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use nostr_sdk::prelude::*;
use ui::{notification::Notification, ContextModal};
struct GlobalAccount(Entity<Account>);
impl Global for GlobalAccount {}
pub fn init(cx: &mut App) {
Account::set_global(cx.new(|_| Account { profile: None }), cx);
}
#[derive(Debug, Clone)]
pub struct Account {
pub profile: Option<NostrProfile>,
}
impl Account {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalAccount>().0.clone()
}
pub fn set_global(account: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAccount(account));
}
pub fn login<S>(&mut self, signer: S, window: &mut Window, cx: &mut Context<Self>)
where
S: NostrSigner + 'static,
{
let task: Task<Result<NostrProfile, Error>> = cx.background_spawn(async move {
let client = get_client();
// 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();
Ok(NostrProfile::new(public_key, metadata))
});
cx.spawn_in(window, |this, mut cx| async move {
match task.await {
Ok(profile) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.profile = Some(profile);
this.subscribe(cx);
cx.notify();
})
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx)
})
.ok();
}
}
})
.detach();
}
pub fn new_account(&mut self, metadata: Metadata, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let keys = Keys::generate();
let task: Task<Result<NostrProfile, Error>> = cx.background_spawn(async move {
let public_key = keys.public_key();
// Update signer
client.set_signer(keys).await;
// Set metadata
client.set_metadata(&metadata).await?;
Ok(NostrProfile::new(public_key, metadata))
});
cx.spawn_in(window, |this, mut cx| async move {
if let Ok(profile) = task.await {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.profile = Some(profile);
this.subscribe(cx);
cx.notify();
})
})
.ok();
} else {
cx.update(|window, cx| {
window.push_notification(Notification::error("Failed to create account."), cx)
})
.ok();
}
})
.detach();
}
pub fn subscribe(&self, cx: &Context<Self>) {
let Some(profile) = self.profile.as_ref() else {
return;
};
let client = get_client();
let user = profile.public_key;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// 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::ContactList,
Kind::InboxRelays,
Kind::RelayList,
]);
// 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);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// 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(())
});
cx.spawn(|_, _| async move {
if let Err(e) = task.await {
log::error!("Error: {}", e);
}
})
.detach();
}
}

View File

@@ -13,6 +13,7 @@ ui = { path = "../ui" }
common = { path = "../common" }
global = { path = "../global" }
chats = { path = "../chats" }
account = { path = "../account" }
gpui.workspace = true
reqwest_client.workspace = true
@@ -29,7 +30,8 @@ log.workspace = true
smallvec.workspace = true
smol.workspace = true
oneshot.workspace = true
keyring.workspace = true
rustls = "0.23.23"
futures= "0.3"
futures = "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -1,21 +1,23 @@
use account::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,
StyledImage, Subscription, Task, Window,
};
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use std::sync::Arc;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, DockArea, DockItem},
dock_area::{dock::DockPlacement, panel::PanelView, DockArea, DockItem},
popup_menu::PopupMenuExt,
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
};
use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome};
use crate::device::Device;
use crate::views::{chat, contacts, profile, relays, settings, welcome};
use crate::views::{onboarding, sidebar};
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
@@ -43,25 +45,80 @@ impl_internal_actions!(dock, [AddPanel]);
// Account actions
actions!(account, [Logout]);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(window, cx)
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
ChatSpace::new(window, cx)
}
pub struct AppView {
pub struct ChatSpace {
titlebar: bool,
dock: Entity<DockArea>,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl AppView {
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
// Initialize dock layout
let account = Account::global(cx);
let dock = cx.new(|cx| DockArea::new(window, cx));
let weak_dock = dock.downgrade();
let titlebar = false;
// Initialize left dock
let left_panel = DockItem::panel(Arc::new(sidebar::init(window, cx)));
cx.new(|cx| {
let mut this = Self {
dock,
titlebar,
subscriptions: smallvec![cx.observe_in(
&account,
window,
|this: &mut ChatSpace, account, window, cx| {
if account.read(cx).profile.is_some() {
this.open_chats(window, cx);
} else {
this.open_onboarding(window, cx);
}
},
)],
};
// Initial central dock
let center_panel = DockItem::split_with_sizes(
if Account::global(cx).read(cx).profile.is_some() {
this.open_chats(window, cx);
} else {
this.open_onboarding(window, cx);
}
this
})
}
pub fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
let panel = Arc::new(panel);
let center = DockItem::panel(panel);
chatspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
});
}
}
}
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(onboarding::init(window, cx));
let center = DockItem::panel(panel);
self.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
}
fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.show_titlebar(cx);
let weak_dock = self.dock.downgrade();
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(welcome::init(window, cx))],
@@ -76,16 +133,18 @@ impl AppView {
cx,
);
// Set default dock layout with left and central docks
_ = weak_dock.update(cx, |view, cx| {
view.set_left_dock(left_panel, Some(px(240.)), true, window, cx);
view.set_center(center_panel, window, cx);
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(240.)), true, window, cx);
this.set_center(center, window, cx);
});
cx.new(|_| Self { dock })
}
fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
fn show_titlebar(&mut self, cx: &mut Context<Self>) {
self.titlebar = true;
cx.notify();
}
fn render_appearance_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("appearance")
.xsmall()
.ghost()
@@ -111,16 +170,17 @@ impl AppView {
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.when_some(Device::global(cx), |this, account| {
this.when_some(account.read(cx).profile(), |this, profile| {
.when_some(
Account::global(cx).read(cx).profile.as_ref(),
|this, profile| {
this.child(
img(profile.avatar.clone())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
})
})
},
)
.popup_menu(move |this, _, _cx| {
this.menu(
"Profile",
@@ -218,21 +278,27 @@ impl AppView {
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let reset: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
client.reset().await;
Ok(())
});
cx.background_spawn(async move {
// Reset nostr client
client.reset().await
cx.spawn_in(window, |_, mut cx| async move {
if reset.await.is_ok() {
cx.update(|_, cx| {
Account::global(cx).update(cx, |this, cx| {
this.profile = None;
cx.notify();
});
})
.ok();
};
})
.detach();
Root::update(window, cx, |this, window, cx| {
this.replace_view(onboarding::init(window, cx).into());
cx.notify();
});
}
}
impl Render for AppView {
impl Render for ChatSpace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
@@ -246,23 +312,25 @@ impl Render for AppView {
.flex_col()
.size_full()
// Title Bar
.child(
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)),
),
)
.when(self.titlebar, |this| {
this.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.px_2()
.child(self.render_appearance_btn(cx))
.child(self.render_relays_btn(cx))
.child(self.render_account_btn(cx)),
),
)
})
// Dock
.child(self.dock.clone()),
)

View File

@@ -1,916 +0,0 @@
use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
use anyhow::{anyhow, Error};
use common::profile::NostrProfile;
use global::{
constants::{
ALL_MESSAGES_SUB_ID, CLIENT_KEYRING, DEVICE_ANNOUNCEMENT_KIND, DEVICE_REQUEST_KIND,
DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, MASTER_KEYRING, NEW_MESSAGE_SUB_ID,
},
get_app_name, get_client, get_device_keys, get_device_name, set_device_keys,
};
use gpui::{
div, px, relative, App, AppContext, 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 {}
#[derive(Debug, Default)]
pub enum DeviceState {
Master,
Minion,
#[default]
None,
}
impl DeviceState {
pub fn subscribe(&self, window: &mut Window, cx: &mut Context<Self>) {
match self {
Self::Master => {
let client = get_client();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let opts =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
.author(public_key)
.limit(1);
// Subscribe for the latest request
client.subscribe(filter, Some(opts)).await?;
let filter = Filter::new()
.kind(Kind::Custom(DEVICE_REQUEST_KIND))
.author(public_key)
.since(Timestamp::now());
// Subscribe for new device requests
client.subscribe(filter, None).await?;
Ok(())
});
cx.spawn_in(window, |_, _cx| async move {
if let Err(err) = task.await {
log::error!("Failed to subscribe for device requests: {}", err);
}
})
.detach();
}
Self::Minion => {
let client = get_client();
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let opts =
SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kind(Kind::Custom(DEVICE_RESPONSE_KIND))
.author(public_key);
// 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(())
});
cx.spawn_in(window, |_, _cx| async move {
if let Err(err) = task.await {
log::error!("Failed to subscribe for device approval: {}", err);
}
})
.detach();
}
_ => {}
}
}
}
/// 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: Arc<Keys>,
/// Device State
state: Entity<DeviceState>,
requesters: Entity<HashSet<PublicKey>>,
is_processing: bool,
}
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();
Arc::new(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;
};
Arc::new(keys)
};
cx.update(|cx| {
let state = cx.new(|_| DeviceState::None);
let weak_state = state.downgrade();
let requesters = cx.new(|_| HashSet::new());
let entity = cx.new(|_| Device {
profile: None,
is_processing: false,
state,
client_keys,
requesters,
});
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 the DeviceState changes
if let Some(state) = weak_state.upgrade() {
window
.observe(&state, cx, |this, window, cx| {
this.update(cx, |this, cx| {
this.subscribe(window, cx);
});
})
.detach();
};
// Observe the Device changes
window
.observe(&entity, cx, |this, window, cx| {
this.update(cx, |this, cx| {
this.on_device_change(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 client_keys(&self) -> Arc<Keys> {
self.client_keys.clone()
}
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();
}
pub fn set_state(&mut self, state: DeviceState, cx: &mut Context<Self>) {
self.state.update(cx, |this, cx| {
*this = state;
cx.notify();
});
}
pub fn set_processing(&mut self, is_processing: bool, cx: &mut Context<Self>) {
self.is_processing = is_processing;
cx.notify();
}
pub fn add_requester(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
self.requesters.update(cx, |this, cx| {
this.insert(public_key);
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),
}
})
}
/// This function is called whenever the device is changed
fn on_device_change(&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();
});
// Get the user's messaging relays
// If it is empty, user must setup relays
let ready = profile.messaging_relays.is_some();
cx.spawn_in(window, |this, mut cx| async move {
cx.update(|window, cx| {
if !ready {
this.update(cx, |this, cx| {
this.render_setup_relays(window, cx);
})
.ok();
} else {
this.update(cx, |this, cx| {
this.start_subscription(cx);
})
.ok();
}
})
.ok();
})
.detach();
}
/// Initialize subscription for current user
pub fn start_subscription(&self, cx: &Context<Self>) {
if self.is_processing {
return;
}
let Some(profile) = self.profile() else {
return;
};
let user = profile.public_key;
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);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Only subscribe to the latest device announcement
let sub_id = SubscriptionId::new(DEVICE_SUB_ID);
client.subscribe_with_id(sub_id, 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(())
});
cx.spawn(|_, _| async move {
if let Err(e) = task.await {
log::error!("Subscription error: {}", e);
}
})
.detach();
}
/// Setup Device
///
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
pub fn setup_device(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(profile) = self.profile().cloned() else {
return;
};
// If processing, return early
if self.is_processing {
return;
}
// Only process if device keys are not set
self.set_processing(true, cx);
let client = get_client();
let public_key = profile.public_key;
let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND);
let filter = Filter::new().kind(kind).author(public_key).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() {
Ok(event)
} else {
Err(anyhow!("Device Announcement not found."))
}
});
cx.spawn_in(window, |this, mut cx| async move {
// Device Keys has been set, no need to retrieve device announcement again
if get_device_keys().await.is_some() {
return;
}
match fetch_announcement.await {
Ok(event) => {
log::info!("Found a device announcement: {:?}", event);
let n_tag = event
.tags
.find(TagKind::custom("n"))
.and_then(|t| t.content())
.map(|hex| hex.to_owned());
let credentials_task =
match cx.update(|_, cx| cx.read_credentials(MASTER_KEYRING)) {
Ok(task) => task,
Err(err) => {
log::error!("Failed to read credentials: {:?}", err);
log::info!("Trying to request keys from Master Device...");
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.request_master_keys(window, cx);
})
})
.ok();
return;
}
};
match credentials_task.await {
Ok(Some((pubkey, secret))) if n_tag.as_deref() == Some(&pubkey) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_master_keys(secret, window, cx);
})
})
.ok();
}
_ => {
log::info!("This device is not the Master Device.");
log::info!("Trying to request keys from Master Device...");
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.request_master_keys(window, cx);
})
})
.ok();
}
}
}
Err(_) => {
log::info!("Device Announcement not found.");
log::info!("Appoint this device as Master Device.");
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_new_master_keys(window, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
/// Create a new Master Keys, appointing this device as Master Device.
///
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
pub fn set_new_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
let client = get_client();
let app_name = get_app_name();
let task: Task<Result<Arc<Keys>, Error>> = cx.background_spawn(async move {
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 event = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]);
if let Err(e) = client.send_event_builder(event).await {
log::error!("Failed to send Device Announcement: {}", e);
} else {
log::info!("Device Announcement has been sent");
}
Ok(Arc::new(keys))
});
cx.spawn_in(window, |this, mut cx| async move {
if get_device_keys().await.is_some() {
return;
}
if let Ok(keys) = task.await {
// Update global state
set_device_keys(keys.clone()).await;
// Save keys
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(),
)
}) {
if let Err(e) = task.await {
log::error!("Failed to write device keys to keyring: {}", e);
}
};
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_state(DeviceState::Master, cx);
})
.ok();
})
.ok();
}
})
.detach();
}
/// Device already has Master Keys, re-appointing this device as Master Device.
///
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
pub fn set_master_keys(&self, secret: Vec<u8>, window: &mut Window, cx: &Context<Self>) {
let Ok(secret_key) = SecretKey::from_slice(&secret) else {
log::error!("Failed to parse secret key");
return;
};
let keys = Arc::new(Keys::new(secret_key));
cx.spawn_in(window, |this, mut cx| async move {
log::info!("Re-appointing this device as Master Device.");
set_device_keys(keys).await;
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_state(DeviceState::Master, cx);
})
.ok();
})
.ok();
})
.detach();
}
/// 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>
pub fn request_master_keys(&self, window: &mut Window, cx: &Context<Self>) {
let client = get_client();
let app_name = get_app_name();
let client_keys = self.client_keys.clone();
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]);
let task: Task<Result<(), Error>> = 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);
} else {
log::info!("Waiting for response...");
}
Ok(())
});
cx.spawn_in(window, |this, mut cx| async move {
if task.await.is_ok() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_state(DeviceState::Minion, cx);
this.render_waiting_modal(window, cx);
})
.ok();
})
.ok();
}
})
.detach();
}
/// Received Device Keys approval from Master Device,
///
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
pub fn recv_approval(&self, event: Event, window: &mut Window, cx: &Context<Self>) {
let local_signer = self.client_keys.clone();
let task = cx.background_spawn(async move {
if let Some(tag) = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
{
if let Ok(public_key) = PublicKey::from_str(tag) {
let secret = local_signer
.nip44_decrypt(&public_key, &event.content)
.await?;
let keys = Arc::new(Keys::parse(&secret)?);
// Update global state with new device keys
set_device_keys(keys).await;
log::info!("Received master keys");
Ok(())
} else {
Err(anyhow!("Public Key is invalid"))
}
} else {
Err(anyhow!("Failed to decrypt the Master Keys"))
}
});
cx.spawn_in(window, |_, mut cx| async move {
// No need to update if device keys are already available
if get_device_keys().await.is_some() {
return;
}
if let Err(e) = task.await {
cx.update(|window, cx| {
window.push_notification(
Notification::error(format!("Failed to decrypt: {}", e)),
cx,
);
})
.ok();
} else {
cx.update(|window, cx| {
window.close_all_modals(cx);
window.push_notification(
Notification::success("Device Keys request has been approved"),
cx,
);
})
.ok();
}
})
.detach();
}
/// Received Master Keys request from other Nostr client
///
/// NIP-4e: <https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md>
pub fn recv_request(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let Some(target_pubkey) = event
.tags
.find(TagKind::custom("pubkey"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
else {
log::error!("Invalid public key.");
return;
};
// Prevent processing duplicate requests
if self.requesters.read(cx).contains(&target_pubkey) {
return;
}
self.add_requester(target_pubkey, cx);
let client = get_client();
let read_keys = cx.read_credentials(MASTER_KEYRING);
let local_signer = self.client_keys.clone();
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)?;
let device_secret_hex = device_secret.to_secret_hex();
// Encrypt device's secret key by using NIP-44
let content = local_signer
.nip44_encrypt(&target_pubkey, &device_secret_hex)
.await?;
// Create pubkey tag for other device (lowercase p)
let other_tag = Tag::public_key(target_pubkey);
// 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(430.))
.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(430.))
.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 ..."),
)
});
}
}

View File

@@ -1,16 +1,11 @@
use anyhow::anyhow;
use asset::Assets;
use chats::registry::ChatRegistry;
use device::Device;
use chats::ChatRegistry;
use futures::{select, FutureExt};
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::{
constants::{
ALL_MESSAGES_SUB_ID, APP_ID, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND,
DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, DEVICE_SUB_ID, NEW_MESSAGE_SUB_ID,
},
get_client, get_device_keys, set_device_name,
constants::{ALL_MESSAGES_SUB_ID, APP_ID, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID},
get_client,
};
use gpui::{
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
@@ -21,16 +16,15 @@ use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use nostr_sdk::{
nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId,
};
use smol::Timer;
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use ui::{theme::Theme, Root};
use views::startup;
pub(crate) mod asset;
pub(crate) mod device;
pub(crate) mod chat_space;
pub(crate) mod views;
actions!(coop, [Quit]);
@@ -39,12 +33,6 @@ actions!(coop, [Quit]);
enum Signal {
/// Receive event
Event(Event),
/// Receive request master key event
RequestMasterKey(Event),
/// Receive approve master key event
ReceiveMasterKey(Event),
/// Receive announcement event
ReceiveAnnouncement,
/// Receive EOSE
Eose,
}
@@ -121,7 +109,6 @@ fn main() {
let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let device_id = SubscriptionId::new(DEVICE_SUB_ID);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
@@ -133,7 +120,7 @@ fn main() {
} => {
match event.kind {
Kind::GiftWrap => {
if let Ok(gift) = handle_gift_wrap(&event).await {
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
// Sign the rumor with the generated keys,
// this event will be used for internal only,
// and NEVER send to relays.
@@ -161,45 +148,12 @@ fn main() {
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 Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
if event.pubkey == public_key {
if let Some(tag) = event
.tags
.find(TagKind::custom("client"))
.and_then(|tag| tag.content())
{
set_device_name(tag).await;
}
}
}
}
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
_ = event_tx.send(Signal::Eose).await;
} else if device_id == *subscription_id {
_ = event_tx.send(Signal::ReceiveAnnouncement).await;
}
}
_ => {}
@@ -256,53 +210,26 @@ fn main() {
})
.detach();
// Initialize components
ui::init(cx);
// Initialize chat global state
chats::registry::init(cx);
// Initialize device
device::init(window, cx);
// Root Entity
cx.new(|cx| {
let root = Root::new(startup::init(window, cx).into(), window, cx);
// Initialize components
ui::init(cx);
// Initialize chat state
chats::init(cx);
// Initialize account state
account::init(cx);
// Spawn a task to handle events from nostr channel
cx.spawn_in(window, |_, mut cx| async move {
let chats = cx.update(|_, cx| ChatRegistry::global(cx)).unwrap();
while let Ok(signal) = event_rx.recv().await {
cx.update(|window, cx| {
cx.update(|_, cx| {
match signal {
Signal::Eose => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(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::ReceiveAnnouncement => {
if let Some(device) = Device::global(cx) {
device.update(cx, |this, cx| {
this.setup_device(window, cx);
});
}
}
Signal::ReceiveMasterKey(event) => {
if let Some(device) = Device::global(cx) {
device.update(cx, |this, cx| {
this.recv_approval(event, window, cx);
});
}
}
Signal::RequestMasterKey(event) => {
if let Some(device) = Device::global(cx) {
device.update(cx, |this, cx| {
this.recv_request(event, window, cx);
});
}
chats.update(cx, |this, cx| this.push_message(event, cx));
}
};
})
@@ -311,43 +238,24 @@ fn main() {
})
.detach();
root
Root::new(chat_space::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
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)));
.idle_timeout(Some(Duration::from_secs(1)));
let filter = Filter::new()
.authors(buffer.iter().cloned())
.limit(buffer.len() * 2)
.kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
.limit(100)
.kinds(vec![Kind::Metadata, Kind::UserStatus]);
if let Err(e) = client.subscribe(filter, Some(opts)).await {
log::error!("Failed to sync metadata: {e}");

View File

@@ -1,6 +1,6 @@
use anyhow::anyhow;
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use common::{
last_seen::LastSeen,
profile::NostrProfile,
@@ -37,14 +37,10 @@ pub fn init(
window: &mut Window,
cx: &mut App,
) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(chats) = ChatRegistry::global(cx) {
if let Some(room) = chats.read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat room is not exist"))
}
if let Some(room) = ChatRegistry::global(cx).read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat Registry is not initialized"))
Err(anyhow!("Chat room is not exist"))
}
}

View File

@@ -0,0 +1,433 @@
use std::time::Duration;
use account::Account;
use common::utils::create_qr;
use global::get_client_keys;
use gpui::{
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const INPUT_INVALID: &str = "You must provide a valid Private Key or Bunker.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
// Inputs
key_input: Entity<TextInput>,
error_message: Entity<Option<SharedString>>,
is_logging_in: bool,
// Nostr Connect
connect_relay: Entity<TextInput>,
connect_client: Entity<Option<NostrConnectURI>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
let error_message = cx.new(|_| None);
let connect_client = cx.new(|_: &mut Context<'_, Option<NostrConnectURI>>| None);
let key_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("nsec... or bunker://...")
});
let connect_relay = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
input.set_text("wss://relay.nsec.app", window, cx);
input
});
subscriptions.push(cx.subscribe_in(
&key_input,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.login(window, cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&connect_relay,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.change_relay(window, cx);
}
},
));
subscriptions.push(
cx.observe_in(&connect_client, window, |_, this, window, cx| {
let keys = get_client_keys().to_owned();
let account = Account::global(cx);
if let Some(uri) = this.read(cx) {
match NostrConnect::new(uri.to_owned(), keys, Duration::from_secs(300), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
}
}),
);
cx.spawn(|this, cx| async move {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
cx.update(|cx| {
this.update(cx, |this, cx| {
let Ok(relay_url) =
RelayUrl::parse(this.connect_relay.read(cx).text().to_string().as_str())
else {
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
this.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
})
})
.ok();
})
.detach();
Self {
key_input,
connect_relay,
connect_client,
subscriptions,
error_message,
is_logging_in: false,
name: "Login".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_logging_in(true, cx);
let content = self.key_input.read(cx).text();
let account = Account::global(cx);
if content.starts_with("nsec1") {
match SecretKey::parse(content.as_ref()) {
Ok(secret) => {
let keys = Keys::new(secret);
account.update(cx, |this, cx| {
this.login(keys, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else if content.starts_with("bunker://") {
let keys = get_client_keys().to_owned();
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
self.set_error_message("Bunker URL is not valid".to_owned(), cx);
self.set_logging_in(false, cx);
return;
};
match NostrConnect::new(uri, keys, Duration::from_secs(120), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else {
self.set_logging_in(false, cx);
window.push_notification(Notification::error(INPUT_INVALID), cx);
};
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) =
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
else {
window.push_notification(Notification::error("Relay URL is not valid."), cx);
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
self.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
}
fn set_error_message(&mut self, message: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(SharedString::new(message));
cx.notify();
});
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.child(
div()
.text_center()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome Back!"),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Continue with Private Key or Bunker"),
),
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(self.key_input.clone())
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.is_logging_in)
.disabled(self.is_logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(
self.error_message.read(cx).clone(),
|this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error),
)
},
),
),
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.2))
.text_color(
cx.theme().base.step(cx, ColorScaleStep::TWELVE),
)
.child("Continue with Nostr Connect"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Use Nostr Connect apps to scan the code"),
),
)
.when_some(self.connect_client.read(cx).as_ref(), |this, uri| {
if let Ok(qr) = create_qr(uri.to_string().as_ref()) {
this.child(
div()
.mb_2()
.p_2()
.size_64()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.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(qr).h_56()),
)
} else {
this
}
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(self.connect_relay.clone())
.child(
Button::new("change")
.label("Change")
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}

View File

@@ -1,11 +1,10 @@
mod chat;
mod contacts;
mod profile;
mod settings;
mod sidebar;
mod welcome;
pub mod app;
pub mod chat;
pub mod contacts;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod startup;
pub mod settings;
pub mod sidebar;
pub mod welcome;

View File

@@ -0,0 +1,363 @@
use account::Account;
use async_utility::task::spawn;
use common::utils::nip96_upload;
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{
div, img, prelude::FluentBuilder, px, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use std::str::FromStr;
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const STEAM_ID_DESCRIPTION: &str =
"Steam ID is used to get your currently playing game and update your status.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
}
pub struct NewAccount {
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
steam_input: Entity<TextInput>,
is_uploading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl NewAccount {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("Alice")
});
let avatar_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.jpg")
});
let steam_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("76561199810385277")
});
let bio_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line()
.placeholder("A short introduce about you.")
});
Self {
name_input,
avatar_input,
steam_input,
bio_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let steam = self.steam_input.read(cx).text().to_string();
let mut metadata = Metadata::new()
.display_name(name)
.about(bio)
.custom_field("steam", steam);
if let Ok(url) = Url::from_str(&avatar) {
metadata = metadata.picture(url);
};
Account::global(cx).update(cx, |this, cx| {
this.new_account(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
self.set_uploading(true, cx);
cx.spawn_in(window, |this, mut cx| async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
return;
};
if let Ok(file_data) = fs::read(path).await {
let client = get_client();
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_text(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Create New Account"),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_3()
.child(
div()
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
if self.avatar_input.read(cx).text().is_empty() {
this.child(
img("brand/avatar.jpg")
.size_10()
.rounded_full()
.flex_shrink_0(),
)
} else {
this.child(
img(format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE,
self.avatar_input.read(cx).text()
))
.size_10()
.rounded_full()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.label("Set Profile Picture")
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name *:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Steam ID:")
.child(self.steam_input.clone())
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(STEAM_ID_DESCRIPTION),
),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("submit")
.label("Continue")
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}

View File

@@ -1,399 +1,158 @@
use common::qr::create_qr;
use gpui::{
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Subscription, Window,
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use std::{path::PathBuf, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput},
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Disableable, Size, StyledExt,
Icon, IconName, StyledExt,
};
use crate::device::Device;
use crate::chat_space::ChatSpace;
use super::{login, new_account};
const LOGO_URL: &str = "brand/coop.svg";
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "A Nostr client for secure communication.";
// TODO: Replace it with Persona Mobile App
const NSTART_URL: &str =
"https://start.njump.me?an=Coop&at=ios&ac=coop&afb=yes&asf=yes&aan=null&aac=null&arr=wss://relay.damus.io&awr=wss://relay.primal.net,wss://purplerelay.com,wss://offchain.pub";
const SUBTITLE: &str = "a Nostr client for secure communication.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
enum PageKind {
Bunker,
Connect,
Selection,
}
pub struct Onboarding {
bunker_input: Entity<TextInput>,
connect_url: Entity<Option<PathBuf>>,
error_message: Entity<Option<SharedString>>,
open: PageKind,
is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
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")
});
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,
}
})
cx.new(|cx| Self::view(window, cx))
}
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 Some(model) = Device::global(cx) else {
return;
};
let text = self.bunker_input.read(cx).text().to_string();
let keys = Arc::unwrap_or_clone(model.read(cx).client_keys());
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",
);
// 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();
});
// Open Connect page
self.open(PageKind::Connect, window, cx);
// Wait for connection
if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
self.login(signer, window, cx);
} else {
self.set_loading(false, cx);
self.set_error("Failed to establish connection".to_owned(), cx);
self.open(PageKind::Selection, window, cx);
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Onboarding".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
fn open_new_account(&self, window: &mut Window, cx: &mut Context<Self>) {
let new_account = new_account::init(window, cx);
ChatSpace::set_center_panel(new_account, window, cx);
}
fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(msg.into());
cx.notify();
});
fn open_login(&self, window: &mut Window, cx: &mut Context<Self>) {
let login = login::init(window, cx);
ChatSpace::set_center_panel(login, window, cx);
}
}
// 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();
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
self.open = kind;
cx.notify();
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.py_4()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_8()
.gap_4()
.child(
svg()
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_4()
.text_center()
.child(
svg()
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child(TITLE),
)
.child(
div()
.text_center()
.child(
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child(TITLE),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child(SUBTITLE),
),
.text_sm()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(SUBTITLE),
),
),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_new_account(window, cx);
})),
)
.child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
match self.open {
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")
.primary()
.w_full()
.loading(true)
.disabled(true),
)
.child(
Button::new("use_url")
.label("Use Bunker 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.open(PageKind::Bunker, 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::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(
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.wait_for_connection(window, cx);
})),
)
.child(
Button::new("join_btn")
.label("Are you new? Join here!")
.ghost()
.w_full()
.on_click(|_, _, cx| {
cx.open_url(NSTART_URL);
}),
),
}
})),
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_login(window, cx);
})),
),
)
}
}

View File

@@ -14,8 +14,6 @@ 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.";
@@ -113,12 +111,11 @@ impl Relays {
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let relays = self.relays.read(cx).clone();
let window_handle = window.window_handle();
// Show loading spinner
self.set_loading(true, cx);
let relays = self.relays.read(cx).clone();
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
@@ -173,27 +170,14 @@ impl Relays {
Ok(output.val)
});
cx.spawn(|this, mut cx| async move {
cx.spawn_in(window, |this, mut cx| async move {
if task.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| {
cx.update(|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();

View File

@@ -1,4 +1,4 @@
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use common::{profile::NostrProfile, utils::random_name};
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
use gpui::{
@@ -166,13 +166,12 @@ impl Compose {
if let Ok(event) = event.await {
_ = cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner
_ = this.update(cx, |this, cx| {
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
});
})
.ok();
let Some(chats) = ChatRegistry::global(cx) else {
return;
};
let chats = ChatRegistry::global(cx);
let room = Room::new(&event, cx);
chats.update(cx, |state, cx| {

View File

@@ -1,4 +1,4 @@
use chats::{registry::ChatRegistry, room::Room};
use chats::{room::Room, ChatRegistry};
use compose::Compose;
use gpui::{
div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
@@ -15,7 +15,7 @@ use ui::{
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
};
use super::app::AddPanel;
use crate::chat_space::{AddPanel, PanelKind};
mod compose;
@@ -159,7 +159,7 @@ impl Sidebar {
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
super::app::PanelKind::Room(id),
PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
@@ -278,69 +278,66 @@ impl Render for Sidebar {
})),
)
.when(!self.is_collapsed, |this| {
this.flex_1()
.w_full()
.when_some(ChatRegistry::global(cx), |this, state| {
let is_loading = state.read(cx).is_loading();
let len = state.read(cx).rooms().len();
this.flex_1().w_full().map(|this| {
let state = ChatRegistry::global(cx);
let is_loading = state.read(cx).is_loading();
let len = state.read(cx).rooms().len();
if is_loading {
this.children(self.render_skeleton(5))
} else if state.read(cx).rooms().is_empty() {
this.child(
div()
.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.rounded(px(cx.theme().radius))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No chats"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme()
.base
.step(cx, ColorScaleStep::ELEVEN),
)
.child("Recent chats will appear here."),
),
)
} else {
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) =
state.read(cx).rooms().get(ix)
{
items.push(this.render_room(ix, room, cx));
}
}
items
},
if is_loading {
this.children(self.render_skeleton(5))
} else if state.read(cx).rooms().is_empty() {
this.child(
div()
.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.rounded(px(cx.theme().radius))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No chats"),
)
.size_full(),
.child(
div()
.text_xs()
.text_color(
cx.theme()
.base
.step(cx, ColorScaleStep::ELEVEN),
)
.child("Recent chats will appear here."),
),
)
} else {
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) = state.read(cx).rooms().get(ix) {
items.push(this.render_room(ix, room, cx));
}
}
items
},
)
}
})
.size_full(),
)
}
})
}),
)
}

View File

@@ -1,32 +0,0 @@
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)),
)
}
}

View File

@@ -38,7 +38,7 @@ impl Welcome {
impl Panel for Welcome {
fn panel_id(&self) -> SharedString {
"WelcomePanel".into()
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {

View File

@@ -1,2 +1,168 @@
pub mod registry;
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 crate::room::{IncomingEvent, Room};
pub mod room;
pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(|_| ChatRegistry::new()), cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {}
pub struct ChatRegistry {
rooms: Vec<Entity<Room>>,
is_loading: bool,
}
impl ChatRegistry {
pub fn global(cx: &mut App) -> Entity<Self> {
cx.global::<GlobalChatRegistry>().0.clone()
}
pub fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalChatRegistry(state));
}
fn new() -> Self {
Self {
rooms: vec![],
is_loading: true,
}
}
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
self.rooms.iter().map(|room| room.read(cx).id).collect()
}
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
let client = get_client();
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key);
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.pubkey(public_key);
let send_events = client.database().query(send).await?;
let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events);
let result: Vec<Event> = events
.into_iter()
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
.unique_by(room_hash)
.sorted_by_key(|ev| Reverse(ev.created_at))
.collect();
Ok(result)
});
cx.spawn(|this, cx| async move {
if let Ok(events) = task.await {
_ = 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()
.filter_map(|ev| {
let new = room_hash(&ev);
// Filter all seen rooms
if !current_ids.iter().any(|this| this == &new) {
Some(Room::new(&ev, cx))
} else {
None
}
})
.collect();
this.is_loading = false;
this.rooms.extend(items);
this.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
} else {
this.is_loading = false;
}
cx.notify();
});
});
}
})
.detach();
}
pub fn rooms(&self) -> &[Entity<Room>] {
&self.rooms
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|model| model.read(cx).id == *id)
.map(|room| room.downgrade())
}
pub fn push_room(
&mut self,
room: Entity<Room>,
cx: &mut Context<Self>,
) -> Result<(), anyhow::Error> {
if !self
.rooms
.iter()
.any(|current| current.read(cx) == room.read(cx))
{
self.rooms.insert(0, room);
cx.notify();
Ok(())
} else {
Err(anyhow!("Room already exists"))
}
}
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
let id = room_hash(&event);
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
room.update(cx, |this, cx| {
this.last_seen = LastSeen(event.created_at);
cx.emit(IncomingEvent { event });
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);
// Push the new room to the front of the list
self.rooms.insert(0, new_room);
}
cx.notify();
}
}

View File

@@ -1,174 +0,0 @@
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 crate::room::{IncomingEvent, Room};
pub fn init(cx: &mut App) {
ChatRegistry::register(cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {}
pub struct ChatRegistry {
rooms: Vec<Entity<Room>>,
is_loading: bool,
}
impl ChatRegistry {
pub fn global(cx: &mut App) -> Option<Entity<Self>> {
cx.try_global::<GlobalChatRegistry>()
.map(|global| global.0.clone())
}
pub fn register(cx: &mut App) -> Entity<Self> {
Self::global(cx).unwrap_or_else(|| {
let entity = cx.new(Self::new);
// Set global state
cx.set_global(GlobalChatRegistry(entity.clone()));
entity
})
}
fn new(_cx: &mut Context<Self>) -> Self {
Self {
rooms: vec![],
is_loading: true,
}
}
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
self.rooms.iter().map(|room| room.read(cx).id).collect()
}
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
let client = get_client();
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(public_key);
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.pubkey(public_key);
let send_events = client.database().query(send).await?;
let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events);
let result: Vec<Event> = events
.into_iter()
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
.unique_by(room_hash)
.sorted_by_key(|ev| Reverse(ev.created_at))
.collect();
Ok(result)
});
cx.spawn(|this, cx| async move {
if let Ok(events) = task.await {
_ = 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()
.filter_map(|ev| {
let new = room_hash(&ev);
// Filter all seen rooms
if !current_ids.iter().any(|this| this == &new) {
Some(Room::new(&ev, cx))
} else {
None
}
})
.collect();
this.is_loading = false;
this.rooms.extend(items);
this.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
} else {
this.is_loading = false;
}
cx.notify();
});
});
}
})
.detach();
}
pub fn rooms(&self) -> &[Entity<Room>] {
&self.rooms
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|model| model.read(cx).id == *id)
.map(|room| room.downgrade())
}
pub fn push_room(
&mut self,
room: Entity<Room>,
cx: &mut Context<Self>,
) -> Result<(), anyhow::Error> {
if !self
.rooms
.iter()
.any(|current| current.read(cx) == room.read(cx))
{
self.rooms.insert(0, room);
cx.notify();
Ok(())
} else {
Err(anyhow!("Room already exists"))
}
}
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
let id = room_hash(&event);
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
room.update(cx, |this, cx| {
this.last_seen = LastSeen(event.created_at);
cx.emit(IncomingEvent { event });
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);
// Push the new room to the front of the list
self.rooms.insert(0, new_room);
}
cx.notify();
}
}

View File

@@ -1,12 +1,8 @@
use std::collections::HashSet;
use anyhow::{anyhow, Error};
use common::{
last_seen::LastSeen,
profile::NostrProfile,
utils::{device_pubkey, room_hash},
};
use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys};
use anyhow::Error;
use common::{last_seen::LastSeen, profile::NostrProfile, utils::room_hash};
use global::get_client;
use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
@@ -170,19 +166,14 @@ impl Room {
let pubkeys = self.public_keys();
cx.background_spawn(async move {
let Some(device) = get_device_keys().await else {
return Err(anyhow!("Device not found. Please restart the application."));
};
let user_signer = client.signer().await?;
let user_pubkey = user_signer.get_public_key().await?;
let mut report = Vec::with_capacity(pubkeys.len());
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let mut report = vec![];
let tags: Vec<Tag> = pubkeys
.iter()
.filter_map(|pubkey| {
if pubkey != &user_pubkey {
if pubkey != &public_key {
Some(Tag::public_key(*pubkey))
} else {
None
@@ -191,41 +182,10 @@ impl Room {
.collect();
for pubkey in pubkeys.iter() {
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
let event = match client.database().query(filter).await?.first() {
Some(event) => {
log::info!("Use device signer to send message");
let signer = &device;
// Get the device's public key of other user
let device_pubkey = device_pubkey(event)?;
let rumor = EventBuilder::private_msg_rumor(*pubkey, &content)
.tags(tags.clone())
.build(user_pubkey);
EventBuilder::gift_wrap(
signer,
&device_pubkey,
rumor,
vec![Tag::public_key(*pubkey)],
)
.await?
}
None => {
log::info!("Use user signer to send message");
let signer = client.signer().await?;
EventBuilder::private_msg(&signer, *pubkey, &content, tags.clone()).await?
}
};
if let Err(e) = client.send_event(&event).await {
if let Err(e) = client
.send_private_msg(*pubkey, &content, tags.clone())
.await
{
report.push(e.to_string());
}
}

View File

@@ -12,7 +12,6 @@ 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"

View File

@@ -1,4 +1,3 @@
pub mod last_seen;
pub mod profile;
pub mod qr;
pub mod utils;

View File

@@ -1,14 +1,12 @@
use global::constants::IMAGE_SERVICE;
use gpui::SharedString;
use nostr_sdk::prelude::*;
use smallvec::SmallVec;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrProfile {
pub public_key: PublicKey,
pub avatar: SharedString,
pub name: SharedString,
pub messaging_relays: Option<SmallVec<[RelayUrl; 3]>>,
}
impl NostrProfile {
@@ -20,16 +18,9 @@ impl NostrProfile {
public_key,
name,
avatar,
messaging_relays: None,
}
}
/// 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 {
metadata
.picture

View File

@@ -1,13 +0,0 @@
use std::path::PathBuf;
use dirs::config_dir;
use qrcode_generator::QrCodeEcc;
pub fn create_qr(data: &str) -> Result<PathBuf, anyhow::Error> {
let config_dir = config_dir().expect("Config directory not found");
let path = config_dir.join("Coop/nostr_connect.png");
qrcode_generator::to_png_to_file(data, QrCodeEcc::Low, 512, &path)?;
Ok(path)
}

View File

@@ -1,11 +1,14 @@
use anyhow::Context;
use global::constants::NIP96_SERVER;
use gpui::Image;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use qrcode_generator::QrCodeEcc;
use rnglib::{Language, RNG};
use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
sync::Arc,
};
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
@@ -57,6 +60,17 @@ pub fn random_name(length: usize) -> String {
rng.generate_names(length, true).join("-").to_lowercase()
}
pub fn create_qr(data: &str) -> Result<Arc<Image>, anyhow::Error> {
let qr = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256)?;
let img = Arc::new(Image {
format: gpui::ImageFormat::Png,
bytes: qr.clone(),
id: 1,
});
Ok(img)
}
pub fn compare<T>(a: &[T], b: &[T]) -> bool
where
T: Eq + Hash,

View File

@@ -1,6 +1,7 @@
pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const KEYRING_SERVICE: &str = "Coop Safe Storage";
pub const CLIENT_KEYRING: &str = "Coop Client Keys";
pub const MASTER_KEYRING: &str = "Coop Master Keys";
@@ -25,9 +26,3 @@ pub const IMAGE_SERVICE: &str = "https://wsrv.nl";
/// NIP96 Media Server
pub const NIP96_SERVER: &str = "https://nostrmedia.com";
/// Updater Public Key
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxM0EzQTQyRTBBMENENTYKUldSV3phRGdRam82a1dtU0JqYll4VnBaVUpSWUxCWlVQbnRkUnNERS96MzFMWDhqNW5zOXplMEwK";
/// Updater Server URL
pub const UPDATER_URL: &str =
"https://cdn.crabnebula.app/update/lume/coop/{{target}}-{{arch}}/{{current_version}}";

View File

@@ -1,37 +1,20 @@
use constants::{ALL_MESSAGES_SUB_ID, APP_ID};
use dirs::config_dir;
use nostr_sdk::prelude::*;
use smol::lock::Mutex;
use paths::nostr_file;
use std::{
fs,
sync::{Arc, OnceLock},
time::Duration,
};
use std::{sync::OnceLock, time::Duration};
pub mod constants;
pub mod paths;
/// 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);
static CLIENT_KEYS: OnceLock<Keys> = OnceLock::new();
/// 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");
let db_path = nostr_file();
let lmdb = NostrLMDB::open(db_path).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
@@ -45,60 +28,7 @@ pub fn get_client() -> &'static Client {
})
}
/// 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: Arc<T>)
where
T: NostrSigner + 'static,
{
DEVICE_KEYS.lock().await.replace(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()))
/// Client Keys
pub fn get_client_keys() -> &'static Keys {
CLIENT_KEYS.get_or_init(Keys::generate)
}

View File

@@ -0,0 +1,76 @@
use std::path::PathBuf;
use std::sync::OnceLock;
/// Returns the path to the user's home directory.
pub fn home_dir() -> &'static PathBuf {
static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
}
/// Returns the path to the configuration directory used by Coop.
pub fn config_dir() -> &'static PathBuf {
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
CONFIG_DIR.get_or_init(|| {
if cfg!(target_os = "windows") {
return dirs::config_dir()
.expect("failed to determine RoamingAppData directory")
.join("Coop");
}
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
return if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
flatpak_xdg_config.into()
} else {
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
}
.join("coop");
}
home_dir().join(".config").join("coop")
})
}
/// Returns the path to the support directory used by Coop.
pub fn support_dir() -> &'static PathBuf {
static SUPPORT_DIR: OnceLock<PathBuf> = OnceLock::new();
SUPPORT_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
return home_dir().join("Library/Application Support/Coop");
}
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
return if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
flatpak_xdg_data.into()
} else {
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
}
.join("coop");
}
if cfg!(target_os = "windows") {
return dirs::data_local_dir()
.expect("failed to determine LocalAppData directory")
.join("coop");
}
config_dir().clone()
})
}
/// Returns the path to the `nostr` file.
pub fn nostr_file() -> &'static PathBuf {
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
NOSTR_FILE.get_or_init(|| support_dir().join("nostr"))
}
/// Returns the path to the `client.dat` file.
pub fn client_file() -> &'static PathBuf {
static CLIENT_FILE: OnceLock<PathBuf> = OnceLock::new();
CLIENT_FILE.get_or_init(|| support_dir().join("client.dat"))
}
/// Returns the path to the `device.dat` file.
pub fn device_file() -> &'static PathBuf {
static DEVICE_FILE: OnceLock<PathBuf> = OnceLock::new();
DEVICE_FILE.get_or_init(|| support_dir().join("device.dat"))
}

View File

@@ -348,8 +348,7 @@ impl RenderOnce for Button {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Medium => this.size_8(),
Size::Large => this.size_9(),
_ => this.size_8(),
}
} else {
// Normal Button
@@ -357,6 +356,7 @@ impl RenderOnce for Button {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => this.h_6().px_0p5(),
Size::Small => this.h_7().px_2(),
Size::Large => this.h_10().px_3(),
_ => this.h_8().px_3(),
}
}
@@ -437,10 +437,11 @@ impl RenderOnce for Button {
.id("label")
.items_center()
.justify_center()
.text_xs()
.map(|this| match self.size {
Size::XSmall => this.gap_0p5().text_xs(),
Size::Small => this.gap_1().text_xs(),
_ => this.gap_2().text_xs(),
Size::XSmall => this.gap_0p5(),
Size::Small => this.gap_1(),
_ => this.gap_2().font_medium(),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {

View File

@@ -120,10 +120,10 @@ struct ActiveModal {
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
///
/// It is used to manage the Drawer, Modal, and Notification.
/// It is used to manage the Modal, and Notification.
pub struct Root {
/// Used to store the focus handle of the previous view.
/// When the Modal, Drawer closes, we will focus back to the previous view.
/// When the Modal closes, we will focus back to the previous view.
previous_focus_handle: Option<FocusHandle>,
active_modals: Vec<ActiveModal>,
pub notification: Entity<NotificationList>,

View File

@@ -200,7 +200,7 @@ impl<T: Styled> StyleSized<T> for T {
match size {
Size::Large => self.h_11(),
Size::Medium => self.h_8(),
_ => self.h(px(26.)),
_ => self.h(px(28.)),
}
.input_text_size(size)
}