20 Commits

Author SHA1 Message Date
348dc496a6 chore: bump version 2025-03-13 13:50:16 +07:00
09df38a3b2 chore: fix build on linux 2025-03-13 13:22:31 +07:00
cae96157ca chore: release 0.1.4 2025-03-13 13:02:58 +07:00
0a7f0475a4 chore: small fixes 2025-03-12 16:44:44 +07:00
8156d9d046 chore: small fixes 2025-03-11 13:22:44 +07:00
b92d446184 chore: follow up to 73b8a1a 2025-03-10 14:56:18 +07:00
73b8a1a6da chore: some fixes for nip4e 2025-03-10 13:25:58 +07:00
ba0b377cee chore: update nstart url 2025-03-10 09:40:14 +07:00
0822b46596 feat: follow-up to d93cecb 2025-03-10 08:34:41 +07:00
d93cecbea3 chore: refactor NIP-4E implementation 2025-03-09 18:31:29 +07:00
0887970374 chore: update deps 2025-03-08 19:32:07 +07:00
reya
a53b2181ab 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
2025-03-08 19:29:25 +07:00
81664e3d4e feat: add empty and loading states for the inbox section 2025-02-26 08:01:45 +07:00
29ec6da872 chore: fix the issue when new user cannot see their messages 2025-02-25 18:21:10 +07:00
111ab3b082 chore: internal changes 2025-02-25 15:22:24 +07:00
1c4806bd92 chore: refactor chat room 2025-02-24 16:18:21 +07:00
3f8c02aef8 chore: bump version 2025-02-23 14:14:20 +07:00
b73babf274 feat: add new default avatar 2025-02-23 14:13:56 +07:00
reya
bbc778d5ca feat: sharpen chat experiences (#9)
* feat: add global account and refactor chat registry

* chore: improve last seen

* chore: reduce string alloc

* wip: refactor room

* chore: fix edit profile panel

* chore: refactor open window in main

* chore: refactor sidebar

* chore: refactor room
2025-02-23 08:29:05 +07:00
cfa628a8a6 feat: automatically load inbox on startup 2025-02-19 15:35:14 +07:00
35 changed files with 3352 additions and 2276 deletions

817
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,9 @@ gpui = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" }
# Nostr # Nostr
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" } nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [
"lmdb", "lmdb",
"nip96", "nip96",
"nip59", "nip59",
@@ -23,7 +23,7 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
] } ] }
smol = "2" smol = "2"
oneshot = { git = "https://github.com/faern/oneshot" } oneshot = "0.1.10"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
dirs = "5.0" dirs = "5.0"
@@ -32,7 +32,7 @@ futures = "0.3.30"
chrono = "0.4.38" chrono = "0.4.38"
tracing = "0.1.40" tracing = "0.1.40"
anyhow = "1.0.44" anyhow = "1.0.44"
smallvec = "1.13.2" smallvec = "1.14.0"
rust-embed = "8.5.0" rust-embed = "8.5.0"
log = "0.4" log = "0.4"

View File

@@ -2,7 +2,7 @@ name = "coop"
description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability." description = "Coop is a cross-platform Nostr client designed for secure communication focus on simplicity and customizability."
product-name = "Coop" product-name = "Coop"
identifier = "su.reya.coop" identifier = "su.reya.coop"
version = "0.1.2" version = "0.1.4"
resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"] resources = ["assets/*/*", "Cargo.toml", "./LICENSE", "./README.md"]
icons = [ icons = [
"assets/brand/32x32.png", "assets/brand/32x32.png",

BIN
assets/brand/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "coop" name = "coop"
version = "0.1.2" version = "0.1.4"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -11,7 +11,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
ui = { path = "../ui" } ui = { path = "../ui" }
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } global = { path = "../global" }
chats = { path = "../chats" } chats = { path = "../chats" }
gpui.workspace = true gpui.workspace = true
@@ -26,6 +26,7 @@ itertools.workspace = true
dirs.workspace = true dirs.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
log.workspace = true log.workspace = true
smallvec.workspace = true
smol.workspace = true smol.workspace = true
oneshot.workspace = true oneshot.workspace = true

916
crates/app/src/device.rs Normal file
View File

@@ -0,0 +1,916 @@
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,55 +1,63 @@
use anyhow::anyhow;
use asset::Assets; use asset::Assets;
use chats::registry::ChatRegistry; use chats::registry::ChatRegistry;
use common::{ use device::Device;
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
profile::NostrProfile,
};
use futures::{select, FutureExt}; 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,
};
use gpui::{ 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, WindowBounds, WindowKind, WindowOptions,
}; };
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
use gpui::{point, SharedString, TitlebarOptions}; use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations}; use gpui::{WindowBackgroundAppearance, WindowDecorations};
use log::{error, info};
use nostr_sdk::{ use nostr_sdk::{
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey, nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey,
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind,
}; };
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
use smol::Timer; use smol::Timer;
use state::{get_client, initialize_client};
use std::{collections::HashSet, mem, sync::Arc, time::Duration}; use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use ui::{theme::Theme, Root}; use ui::{theme::Theme, Root};
use views::{app, onboarding, startup}; use views::startup;
mod asset; pub(crate) mod asset;
mod views; pub(crate) mod device;
pub(crate) mod views;
actions!(coop, [Quit]); actions!(coop, [Quit]);
#[derive(Clone)] #[derive(Debug)]
enum Signal { enum Signal {
/// Receive event /// Receive event
Event(Event), Event(Event),
/// Receive request master key event
RequestMasterKey(Event),
/// Receive approve master key event
ReceiveMasterKey(Event),
/// Receive announcement event
ReceiveAnnouncement,
/// Receive EOSE /// Receive EOSE
Eose, Eose,
} }
fn main() { fn main() {
// Fix crash on startup
// TODO: why this is needed?
_ = rustls::crypto::ring::default_provider().install_default();
// Enable logging // Enable logging
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048); let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100); let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
// Initialize nostr client // Initialize nostr client
let client = initialize_client(); let client = get_client();
// Initialize application // Initialize application
let app = Application::new() let app = Application::new()
@@ -59,11 +67,17 @@ fn main() {
// Connect to default relays // Connect to default relays
app.background_executor() app.background_executor()
.spawn(async { .spawn(async {
_ = client.add_relay("wss://relay.damus.io/").await; // Fix crash on startup
_ = client.add_relay("wss://relay.primal.net/").await; // TODO: why this is needed?
_ = client.add_relay("wss://user.kindpag.es/").await; _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
_ = client.add_relay("wss://purplepag.es/").await;
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://relaydiscovery.com").await;
_ = client.add_discovery_relay("wss://user.kindpag.es").await;
_ = client.connect().await _ = client.connect().await
}) })
.detach(); .detach();
@@ -72,7 +86,7 @@ fn main() {
app.background_executor() app.background_executor()
.spawn(async move { .spawn(async move {
const BATCH_SIZE: usize = 20; 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(); let mut batch: HashSet<PublicKey> = HashSet::new();
@@ -85,7 +99,7 @@ fn main() {
Ok(keys) => { Ok(keys) => {
batch.extend(keys); batch.extend(keys);
if batch.len() >= BATCH_SIZE { if batch.len() >= BATCH_SIZE {
sync_metadata(client, mem::take(&mut batch)).await; handle_metadata(mem::take(&mut batch)).await;
} }
} }
Err(_) => break, Err(_) => break,
@@ -93,7 +107,7 @@ fn main() {
} }
_ = timeout => { _ = timeout => {
if !batch.is_empty() { if !batch.is_empty() {
sync_metadata(client, mem::take(&mut batch)).await; handle_metadata(mem::take(&mut batch)).await;
} }
} }
} }
@@ -107,6 +121,7 @@ fn main() {
let rng_keys = Keys::generate(); let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let device_id = SubscriptionId::new(DEVICE_SUB_ID);
let mut notifications = client.notifications(); let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await { while let Ok(notification) = notifications.recv().await {
@@ -118,52 +133,73 @@ fn main() {
} => { } => {
match event.kind { match event.kind {
Kind::GiftWrap => { Kind::GiftWrap => {
if let Ok(gift) = client.unwrap_gift_wrap(&event).await { if let Ok(gift) = handle_gift_wrap(&event).await {
let mut pubkeys = vec![];
// Sign the rumor with the generated keys, // Sign the rumor with the generated keys,
// this event will be used for internal only, // this event will be used for internal only,
// and NEVER send to relays. // and NEVER send to relays.
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) { if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys()); pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey); pubkeys.push(event.pubkey);
// Save the event to the database, use for query directly. // Save the event to the database, use for query directly.
if let Err(e) = _ = client.database().save_event(&event).await;
client.database().save_event(&event).await
{
error!("Failed to save event: {}", e);
}
// Send all pubkeys to the batch
if let Err(e) = batch_tx.send(pubkeys).await {
error!("Failed to send pubkeys to batch: {}", e)
}
// Send this event to the GPUI // Send this event to the GPUI
if new_id == *subscription_id { if new_id == *subscription_id {
if let Err(e) = _ = event_tx.send(Signal::Event(event)).await;
event_tx.send(Signal::Event(event)).await
{
error!("Failed to send event to GPUI: {}", e)
}
} }
// Send all pubkeys to the batch
_ = batch_tx.send(pubkeys).await;
} }
} }
} }
Kind::ContactList => { Kind::ContactList => {
let pubkeys = let pubkeys =
event.tags.public_keys().copied().collect::<HashSet<_>>(); 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 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) => { RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id { if all_id == *subscription_id {
if let Err(e) = event_tx.send(Signal::Eose).await { _ = event_tx.send(Signal::Eose).await;
error!("Failed to send eose: {}", e) } else if device_id == *subscription_id {
}; _ = event_tx.send(Signal::ReceiveAnnouncement).await;
} }
} }
_ => {} _ => {}
@@ -173,59 +209,24 @@ fn main() {
}) })
.detach(); .detach();
// Handle re-open window
app.on_reopen(move |cx| {
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.background_spawn(async move {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
let metadata =
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
metadata
} else {
Metadata::new()
};
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
} else {
_ = tx.send(None);
}
} else {
_ = tx.send(None);
}
})
.detach();
cx.spawn(|mut cx| async move {
if let Ok(result) = rx.await {
_ = restore_window(result, &mut cx).await;
}
})
.detach();
});
app.run(move |cx| { app.run(move |cx| {
// Initialize chat global state
chats::registry::init(cx);
// Initialize components
ui::init(cx);
// Bring the app to the foreground // Bring the app to the foreground
cx.activate(true); cx.activate(true);
// Register the `quit` function // Register the `quit` function
cx.on_action(quit); cx.on_action(quit);
// Register the `quit` function with CMD+Q // Register the `quit` function with CMD+Q
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Set menu items // Set menu items
cx.set_menus(vec![Menu { cx.set_menus(vec![Menu {
name: "Coop".into(), name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)], items: vec![MenuItem::action("Quit", Quit)],
}]); }]);
// Open window with default options // Set up the window options
cx.open_window( let opts = WindowOptions {
WindowOptions {
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)), title: Some(SharedString::new_static(APP_NAME)),
@@ -242,74 +243,35 @@ fn main() {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client), window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal, kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
..Default::default() ..Default::default()
}, };
|window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))] // Open a window with default options
cx.open_window(opts, |window, cx| {
// Automatically sync theme with system appearance
window window
.observe_window_appearance(|window, cx| { .observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx); Theme::sync_system_appearance(Some(window), cx);
}) })
.detach(); .detach();
let handle = window.window_handle(); // Initialize components
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx)); ui::init(cx);
let task = cx.read_credentials(KEYRING_SERVICE); // Initialize chat global state
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>(); chats::registry::init(cx);
// Read credential in OS Keyring // Initialize device
cx.background_spawn(async { device::init(window, cx);
let profile = if let Ok(Some((npub, secret))) = task.await {
let public_key = PublicKey::from_bech32(&npub).unwrap();
let secret_hex = String::from_utf8(secret).unwrap();
let keys = Keys::parse(&secret_hex).unwrap();
// Update nostr signer cx.new(|cx| {
_ = client.set_signer(keys).await; let root = Root::new(startup::init(window, cx).into(), window, cx);
// Get user's metadata // Spawn a task to handle events from nostr channel
let metadata = if let Ok(Some(metadata)) = cx.spawn_in(window, |_, mut cx| async move {
client.database().metadata(public_key).await
{
metadata
} else {
Metadata::new()
};
Some(NostrProfile::new(public_key, metadata))
} else {
None
};
_ = tx.send(profile)
})
.detach();
// Set root view based on credential status
cx.spawn(|mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
});
} else {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
});
});
}
})
.detach();
cx.spawn(|cx| async move {
while let Ok(signal) = event_rx.recv().await { while let Ok(signal) = event_rx.recv().await {
cx.update(|cx| { cx.update(|window, cx| {
match signal { match signal {
Signal::Eose => { Signal::Eose => {
if let Some(chats) = ChatRegistry::global(cx) { if let Some(chats) = ChatRegistry::global(cx) {
@@ -321,6 +283,27 @@ fn main() {
chats.update(cx, |this, cx| this.push_message(event, 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);
});
}
}
}; };
}) })
.ok(); .ok();
@@ -329,79 +312,49 @@ fn main() {
.detach(); .detach();
root root
}, })
) })
.expect("System error. Please re-open the app."); .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 opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let client = get_client();
let filter = Filter::new()
.authors(buffer.iter().cloned())
.kind(Kind::Metadata)
.limit(buffer.len());
if let Err(e) = client.subscribe(filter, Some(opts)).await { if let Some(device) = get_device_keys().await {
error!("Subscribe error: {e}"); // 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 restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> { async fn handle_metadata(buffer: HashSet<PublicKey>) {
let opts = cx let client = get_client();
.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 let Some(profile) = profile { let opts = SubscribeAutoCloseOptions::default()
_ = cx.open_window(opts, |window, cx| { .exit_policy(ReqExitPolicy::ExitOnEOSE)
window.set_window_title(APP_NAME); .idle_timeout(Some(Duration::from_secs(2)));
window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))] let filter = Filter::new()
window .authors(buffer.iter().cloned())
.observe_window_appearance(|window, cx| { .limit(buffer.len() * 2)
Theme::sync_system_appearance(Some(window), cx); .kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]);
})
.detach();
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx)) if let Err(e) = client.subscribe(filter, Some(opts)).await {
}); log::error!("Failed to sync metadata: {e}");
} 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) { fn quit(_: &Quit, cx: &mut App) {
info!("Gracefully quitting the application . . ."); log::info!("Gracefully quitting the application . . .");
cx.quit(); cx.quit();
} }

View File

@@ -1,12 +1,10 @@
use common::profile::NostrProfile; use global::get_client;
use gpui::{ use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
StyledImage, Window, StyledImage, Window,
}; };
use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use state::get_client;
use std::sync::Arc; use std::sync::Arc;
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
@@ -16,7 +14,8 @@ use ui::{
ContextModal, Icon, IconName, Root, Sizable, TitleBar, 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)] #[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind { pub enum PanelKind {
@@ -38,21 +37,22 @@ impl AddPanel {
} }
} }
// Dock actions
impl_internal_actions!(dock, [AddPanel]); impl_internal_actions!(dock, [AddPanel]);
// Account actions
actions!(account, [Logout]); actions!(account, [Logout]);
pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<AppView> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(account, window, cx) AppView::new(window, cx)
} }
pub struct AppView { pub struct AppView {
account: NostrProfile,
relays: Entity<Option<Vec<String>>>,
dock: Entity<DockArea>, dock: Entity<DockArea>,
} }
impl AppView { impl AppView {
pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
// Initialize dock layout // Initialize dock layout
let dock = cx.new(|cx| DockArea::new(window, cx)); let dock = cx.new(|cx| DockArea::new(window, cx));
let weak_dock = dock.downgrade(); let weak_dock = dock.downgrade();
@@ -82,87 +82,81 @@ impl AppView {
view.set_center(center_panel, window, cx); view.set_center(center_panel, window, cx);
}); });
cx.new(|cx| { cx.new(|_| Self { dock })
let public_key = account.public_key(); }
let relays = cx.new(|_| None);
let async_relays = relays.downgrade();
// Check user's messaging relays and determine user is ready for NIP17 or not. fn render_mode_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
// If not, show the setup modal and instruct user setup inbox relays Button::new("appearance")
let client = get_client(); .xsmall()
let window_handle = window.window_handle(); .ghost()
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>(); .map(|this| {
if cx.theme().appearance.is_dark() {
let this = Self { this.icon(IconName::Sun)
account, } else {
relays, this.icon(IconName::Moon)
dock, }
};
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let relays = if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
Some(
event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| match t {
TagStandard::Relay(url) => Some(url.to_string()),
_ => None,
}) })
.collect::<Vec<_>>(), .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_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),
) )
} else {
None
}
} else {
None
};
_ = tx.send(relays);
}) })
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(result) = rx.await {
if let Some(relays) = result {
_ = cx.update(|cx| {
_ = async_relays.update(cx, |this, cx| {
*this = 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)
}) })
}); .popup_menu(move |this, _, _cx| {
} this.menu(
} "Profile",
}) Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
.detach(); )
.menu(
this "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_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) { fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
let relays = cx.new(|cx| Relays::new(None, window, cx)); 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| { window.open_modal(cx, move |this, window, cx| {
let is_loading = relays.read(cx).loading(); let is_loading = relays.read(cx).loading();
this.keyboard(false) this.width(px(420.))
.closable(false) .title("Edit your Messaging Relays")
.width(px(420.))
.title("Your Messaging Relays are not configured")
.child(relays.clone()) .child(relays.clone())
.footer( .footer(
div() div()
@@ -185,117 +179,21 @@ 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) -> impl IntoElement {
Button::new("account")
.ghost()
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.child(
img(self.account.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>) { fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
match &action.panel { match &action.panel {
PanelKind::Room(id) => match chat::init(id, window, cx) { PanelKind::Room(id) => {
// User must be logged in to open a room
match chat::init(id, window, cx) {
Ok(panel) => { Ok(panel) => {
self.dock.update(cx, |dock_area, cx| { self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx); dock_area.add_panel(panel, action.position, window, cx);
}); });
} }
Err(e) => window.push_notification(e.to_string(), cx), Err(e) => window.push_notification(e.to_string(), cx),
}, }
}
PanelKind::Profile => { PanelKind::Profile => {
let panel = Arc::new(profile::init(self.account.clone(), window, cx)); let panel = profile::init(window, cx);
self.dock.update(cx, |dock_area, cx| { self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx); dock_area.add_panel(panel, action.position, window, cx);
@@ -319,11 +217,17 @@ impl AppView {
} }
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) { fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
cx.background_spawn(async move { get_client().reset().await }) let client = get_client();
cx.background_spawn(async move {
// Reset nostr client
client.reset().await
})
.detach(); .detach();
window.replace_root(cx, |window, cx| { Root::update(window, cx, |this, window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx) this.replace_view(onboarding::init(window, cx).into());
cx.notify();
}); });
} }
} }
@@ -336,9 +240,12 @@ impl Render for AppView {
div() div()
.relative() .relative()
.size_full() .size_full()
.child(
div()
.flex() .flex()
.flex_col() .flex_col()
// Main .size_full()
// Title Bar
.child( .child(
TitleBar::new() TitleBar::new()
// Left side // Left side
@@ -351,14 +258,19 @@ impl Render for AppView {
.justify_end() .justify_end()
.gap_2() .gap_2()
.px_2() .px_2()
.child(self.render_appearance_button(window, cx)) .child(self.render_mode_btn(cx))
.child(self.render_relays_button(window, cx)) .child(self.render_relays_btn(cx))
.child(self.render_account()), .child(self.render_account_btn(cx)),
), ),
) )
.child(self.dock.clone()) // Dock
.child(self.dock.clone()),
)
// Notifications
.child(div().absolute().top_8().children(notification_layer)) .child(div().absolute().top_8().children(notification_layer))
// Modals
.children(modal_layer) .children(modal_layer)
// Actions
.on_action(cx.listener(Self::on_panel_action)) .on_action(cx.listener(Self::on_panel_action))
.on_action(cx.listener(Self::on_logout_action)) .on_action(cx.listener(Self::on_logout_action))
} }

View File

@@ -2,11 +2,11 @@ use anyhow::anyhow;
use async_utility::task::spawn; use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room}; use chats::{registry::ChatRegistry, room::Room};
use common::{ use common::{
constants::IMAGE_SERVICE,
last_seen::LastSeen, last_seen::LastSeen,
profile::NostrProfile, profile::NostrProfile,
utils::{compare, nip96_upload}, utils::{compare, nip96_upload},
}; };
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{ use gpui::{
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext, div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
@@ -17,7 +17,6 @@ use gpui::{
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smol::fs; use smol::fs;
use state::get_client;
use std::sync::Arc; use std::sync::Arc;
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
@@ -29,7 +28,8 @@ use ui::{
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
}; };
const ALERT: &str = const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
const DESCRIPTION: &str =
"This conversation is private. Only members of this chat can see each other's messages."; "This conversation is private. Only members of this chat can see each other's messages.";
pub fn init( pub fn init(
@@ -39,7 +39,7 @@ pub fn init(
) -> Result<Arc<Entity<Chat>>, anyhow::Error> { ) -> Result<Arc<Entity<Chat>>, anyhow::Error> {
if let Some(chats) = ChatRegistry::global(cx) { if let Some(chats) = ChatRegistry::global(cx) {
if let Some(room) = chats.read(cx).get(id, cx) { if let Some(room) = chats.read(cx).get(id, cx) {
Ok(Arc::new(Chat::new(id, &room, window, cx))) Ok(Arc::new(Chat::new(id, room, window, cx)))
} else { } else {
Err(anyhow!("Chat room is not exist")) Err(anyhow!("Chat room is not exist"))
} }
@@ -58,14 +58,12 @@ struct ParsedMessage {
impl ParsedMessage { impl ParsedMessage {
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self { pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
let avatar = profile.avatar().into();
let display_name = profile.name().into();
let content = SharedString::new(content); let content = SharedString::new(content);
let created_at = LastSeen(created_at).human_readable(); let created_at = LastSeen(created_at).human_readable();
Self { Self {
avatar, avatar: profile.avatar.clone(),
display_name, display_name: profile.name.clone(),
created_at, created_at,
content, content,
} }
@@ -96,14 +94,13 @@ impl Message {
pub struct Chat { pub struct Chat {
// Panel // Panel
id: SharedString, id: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle, focus_handle: FocusHandle,
// Chat Room // Chat Room
room: WeakEntity<Room>, room: WeakEntity<Room>,
messages: Entity<Vec<Message>>, messages: Entity<Vec<Message>>,
new_messages: WeakEntity<Vec<Event>>, seens: Entity<Vec<EventId>>,
list_state: ListState, list_state: ListState,
#[allow(dead_code)]
subscriptions: Vec<Subscription>, subscriptions: Vec<Subscription>,
// New Message // New Message
input: Entity<TextInput>, input: Entity<TextInput>,
@@ -113,14 +110,15 @@ pub struct Chat {
} }
impl Chat { impl Chat {
pub fn new(id: &u64, model: &Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(
let room = model.downgrade(); id: &u64,
let new_messages = model.read(cx).new_messages.downgrade(); room: WeakEntity<Room>,
window: &mut Window,
cx.new(|cx| { cx: &mut App,
) -> Entity<Self> {
let messages = cx.new(|_| vec![Message::placeholder()]); let messages = cx.new(|_| vec![Message::placeholder()]);
let seens = cx.new(|_| vec![]);
let attaches = cx.new(|_| None); let attaches = cx.new(|_| None);
let input = cx.new(|cx| { let input = cx.new(|cx| {
TextInput::new(window, cx) TextInput::new(window, cx)
.appearance(false) .appearance(false)
@@ -128,15 +126,28 @@ impl Chat {
.placeholder("Message...") .placeholder("Message...")
}); });
let subscriptions = vec![cx.subscribe_in( cx.new(|cx| {
let mut subscriptions = Vec::with_capacity(2);
subscriptions.push(cx.subscribe_in(
&input, &input,
window, window,
move |this: &mut Chat, _, input_event, window, cx| { move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter = input_event { if let InputEvent::PressEnter = event {
this.send_message(window, cx); this.send_message(window, cx);
} }
}, },
)]; ));
if let Some(room) = room.upgrade() {
subscriptions.push(cx.subscribe_in(
&room,
window,
move |this: &mut Self, _, event, window, cx| {
this.push_message(&event.event, window, cx);
},
));
}
// Initialize list state // Initialize list state
// [item_count] always equal to 1 at the beginning // [item_count] always equal to 1 at the beginning
@@ -150,15 +161,13 @@ impl Chat {
} }
}); });
let mut this = Self { let this = Self {
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
is_uploading: false, is_uploading: false,
id: id.to_string().into(), id: id.to_string().into(),
room, room,
new_messages,
messages, messages,
seens,
list_state, list_state,
input, input,
attaches, attaches,
@@ -171,9 +180,6 @@ impl Chat {
// Load all messages from database // Load all messages from database
this.load_messages(cx); this.load_messages(cx);
// Subscribe and load new messages
this.load_new_messages(cx);
this this
}) })
} }
@@ -184,51 +190,26 @@ impl Chat {
}; };
let room = model.read(cx); let room = model.read(cx);
let pubkeys: Vec<PublicKey> = room.members.iter().map(|m| m.public_key()).collect(); let task = room.verify_inbox_relays(cx);
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, bool)>>();
cx.background_spawn(async move {
let mut result = Vec::new();
for pubkey in pubkeys.into_iter() {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(pubkey)
.limit(1);
let is_ready = if let Ok(events) = client.database().query(filter).await {
events.first_owned().is_some()
} else {
false
};
result.push((pubkey, is_ready));
}
_ = tx.send(result);
})
.detach();
cx.spawn(|this, cx| async move { cx.spawn(|this, cx| async move {
if let Ok(result) = rx.await { if let Ok(result) = task.await {
_ = cx.update(|cx| { _ = cx.update(|cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
for item in result.into_iter() { result.into_iter().for_each(|item| {
if !item.1 { if !item.1 {
let name = this if let Ok(Some(member)) =
.room this.room.read_with(cx, |this, _| this.member(&item.0))
.read_with(cx, |this, _| this.name()) {
.unwrap_or("Unnamed".into());
this.push_system_message( this.push_system_message(
format!("{} has not set up Messaging (DM) Relays, so they will NOT receive your messages.", name), format!("{} {}", member.name, ALERT),
cx, cx,
); );
} }
} }
}); });
}); });
});
} }
}) })
.detach(); .detach();
@@ -239,41 +220,11 @@ impl Chat {
return; return;
}; };
let client = get_client();
let (tx, rx) = oneshot::channel::<Events>();
let room = model.read(cx); let room = model.read(cx);
let pubkeys = room let task = room.load_messages(cx);
.members
.iter()
.map(|m| m.public_key())
.collect::<Vec<_>>();
let recv = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(room.owner.public_key())
.pubkeys(pubkeys.iter().copied());
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(pubkeys)
.pubkey(room.owner.public_key());
cx.background_spawn(async move {
let Ok(recv_events) = client.database().query(recv).await else {
return;
};
let Ok(send_events) = client.database().query(send).await else {
return;
};
let events = recv_events.merge(send_events);
_ = tx.send(events);
})
.detach();
cx.spawn(|this, cx| async move { cx.spawn(|this, cx| async move {
if let Ok(events) = rx.await { if let Ok(events) = task.await {
_ = cx.update(|cx| { _ = cx.update(|cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.push_messages(events, cx); this.push_messages(events, cx);
@@ -296,29 +247,36 @@ impl Chat {
self.list_state.splice(old_len..old_len, 1); self.list_state.splice(old_len..old_len, 1);
} }
fn push_message(&self, content: String, window: &mut Window, cx: &mut Context<Self>) { fn push_message(&mut self, event: &Event, _window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else { let Some(model) = self.room.upgrade() else {
return; return;
}; };
// Prevent duplicate messages
if self.seens.read(cx).iter().any(|id| id == &event.id) {
return;
}
// Add ID to seen list
self.seen(event.id, cx);
let old_len = self.messages.read(cx).len(); let old_len = self.messages.read(cx).len();
let room = model.read(cx); let room = model.read(cx);
let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
// Update message list let profile = room
.member(&event.pubkey)
.unwrap_or(NostrProfile::new(event.pubkey, Metadata::default()));
let message = Message::new(ParsedMessage::new(
&profile,
&event.content,
Timestamp::now(),
));
cx.update_entity(&self.messages, |this, cx| { cx.update_entity(&self.messages, |this, cx| {
this.extend(vec![message]); this.extend(vec![message]);
cx.notify(); cx.notify();
}); });
// Reset message input
cx.update_entity(&self.input, |this, cx| {
this.set_loading(false, window, cx);
this.set_disabled(false, window, cx);
this.set_text("", window, cx);
cx.notify();
});
self.list_state.splice(old_len..old_len, 1); self.list_state.splice(old_len..old_len, 1);
} }
@@ -327,39 +285,35 @@ impl Chat {
return; return;
}; };
let old_len = self.messages.read(cx).len();
let room = model.read(cx); let room = model.read(cx);
let pubkeys = room.pubkeys(); let pubkeys = room.public_keys();
let old_len = self.messages.read(cx).len();
let (messages, total) = { let (messages, new_len) = {
let items: Vec<Message> = events let items: Vec<Message> = events
.into_iter() .into_iter()
.sorted_by_key(|ev| ev.created_at) .sorted_by_key(|ev| ev.created_at)
.filter_map(|ev| { .filter_map(|ev| {
let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect(); let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
other_pubkeys.push(ev.pubkey); other_pubkeys.push(ev.pubkey);
if compare(&other_pubkeys, &pubkeys) { if !compare(&other_pubkeys, &pubkeys) {
let member = if let Some(member) = return None;
room.members.iter().find(|&m| m.public_key() == ev.pubkey)
{
member.to_owned()
} else {
room.owner.to_owned()
};
let message =
Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
Some(message)
} else {
None
} }
room.members
.iter()
.find(|m| m.public_key == ev.pubkey)
.map(|member| {
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
})
}) })
.collect(); .collect();
let total = items.len();
(items, total) // Used for update list state
let new_len = items.len();
(items, new_len)
}; };
cx.update_entity(&self.messages, |this, cx| { cx.update_entity(&self.messages, |this, cx| {
@@ -367,67 +321,7 @@ impl Chat {
cx.notify(); cx.notify();
}); });
self.list_state.splice(old_len..old_len, total); self.list_state.splice(old_len..old_len, new_len);
}
fn load_new_messages(&mut self, cx: &mut Context<Self>) {
let Some(model) = self.new_messages.upgrade() else {
return;
};
let subscription = cx.observe(&model, |view, this, cx| {
let Some(model) = view.room.upgrade() else {
return;
};
let room = model.read(cx);
let old_messages = view.messages.read(cx);
let old_len = old_messages.len();
let items: Vec<Message> = this
.read(cx)
.iter()
.filter_map(|event| {
if let Some(profile) = room.member(&event.pubkey) {
let message = Message::new(ParsedMessage::new(
&profile,
&event.content,
event.created_at,
));
if !old_messages.iter().any(|old| old == &message) {
Some(message)
} else {
None
}
} else {
None
}
})
.collect();
let total = items.len();
cx.update_entity(&view.messages, |this, cx| {
let messages: Vec<Message> = items
.into_iter()
.filter_map(|new| {
if !this.iter().any(|old| old == &new) {
Some(new)
} else {
None
}
})
.collect();
this.extend(messages);
cx.notify();
});
view.list_state.splice(old_len..old_len, total);
});
self.subscriptions.push(subscription);
} }
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -436,7 +330,7 @@ impl Chat {
}; };
// Get message // Get message
let mut content = self.input.read(cx).text(); let mut content = self.input.read(cx).text().to_string();
// Get all attaches and merge its with message // Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() { if let Some(attaches) = self.attaches.read(cx).as_ref() {
@@ -446,7 +340,7 @@ impl Chat {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
content = format!("{}\n{}", content, merged).into() content = format!("{}\n{}", content, merged)
} }
if content.is_empty() { if content.is_empty() {
@@ -460,54 +354,26 @@ impl Chat {
this.set_disabled(true, window, cx); this.set_disabled(true, window, cx);
}); });
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Vec<Error>>();
let room = model.read(cx); let room = model.read(cx);
let pubkeys = room.pubkeys(); let task = room.send_message(content, cx);
let async_content = content.clone().to_string(); let window_handle = window.window_handle();
let tags: Vec<Tag> = room
.pubkeys()
.iter()
.filter_map(|pubkey| {
if pubkey != &room.owner.public_key() {
Some(Tag::public_key(*pubkey))
} else {
None
}
})
.collect();
// Send message to all pubkeys
cx.background_spawn(async move {
let mut errors = Vec::new();
for pubkey in pubkeys.iter() {
if let Err(e) = client
.send_private_msg(*pubkey, &async_content, tags.clone())
.await
{
errors.push(e);
}
}
_ = tx.send(errors);
})
.detach();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
if let Ok(msgs) = task.await {
_ = cx.update_window(window_handle, |_, window, cx| { _ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.push_message(content.to_string(), window, cx); // Reset message input
cx.update_entity(&this.input, |this, cx| {
this.set_loading(false, window, cx);
this.set_disabled(false, window, cx);
this.set_text("", window, cx);
cx.notify();
}); });
}); });
if let Ok(errors) = rx.await { for item in msgs.into_iter() {
_ = cx.update_window(window_handle, |_, window, cx| {
for error in errors.into_iter() {
window.push_notification( window.push_notification(
Notification::error(error.to_string()).title("Message Failed to Send"), Notification::error(item).title("Message Failed to Send"),
cx, cx,
); );
} }
@@ -594,6 +460,13 @@ impl Chat {
cx.notify(); cx.notify();
} }
fn seen(&mut self, id: EventId, cx: &mut Context<Self>) {
self.seens.update(cx, |this, cx| {
this.push(id);
cx.notify();
});
}
fn render_message( fn render_message(
&self, &self,
ix: usize, ix: usize,
@@ -663,7 +536,7 @@ impl Chat {
.group_hover("", |this| this.bg(cx.theme().danger)), .group_hover("", |this| this.bg(cx.theme().danger)),
) )
.child( .child(
img("brand/avatar.png") img("brand/avatar.jpg")
.size_8() .size_8()
.rounded_full() .rounded_full()
.flex_shrink_0(), .flex_shrink_0(),
@@ -688,7 +561,7 @@ impl Chat {
.size_8() .size_8()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
) )
.child(ALERT), .child(DESCRIPTION),
}) })
} else { } else {
div() div()
@@ -703,10 +576,12 @@ impl Panel for Chat {
fn title(&self, cx: &App) -> AnyElement { fn title(&self, cx: &App) -> AnyElement {
self.room self.room
.read_with(cx, |this, _cx| { .read_with(cx, |this, _| {
let name = this.name(); let facepill: Vec<SharedString> = this
let facepill: Vec<String> = .members
this.members.iter().map(|member| member.avatar()).collect(); .iter()
.map(|member| member.avatar.clone())
.collect();
div() div()
.flex() .flex()
@@ -727,20 +602,12 @@ impl Panel for Chat {
) )
})), })),
) )
.child(name) .when_some(this.name(), |this, name| this.child(name))
.into_any() .into_any()
}) })
.unwrap_or("Unnamed".into_any()) .unwrap_or("Unnamed".into_any())
} }
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 { fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle) menu.track_focus(&self.focus_handle)
} }

View File

@@ -1,11 +1,11 @@
use common::profile::NostrProfile; use common::profile::NostrProfile;
use global::get_client;
use gpui::{ use gpui::{
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context, div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Window, Render, SharedString, Styled, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::get_client;
use ui::{ use ui::{
button::Button, button::Button,
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
@@ -141,9 +141,9 @@ impl Render for Contacts {
.child( .child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.child(img(item.avatar()).size_6()), .child(img(item.avatar).size_6()),
) )
.child(item.name()), .child(item.name),
) )
.hover(|this| { .hover(|this| {
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)) this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))

View File

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

View File

@@ -1,139 +1,161 @@
use common::{profile::NostrProfile, qr::create_qr, utils::preload}; use common::qr::create_qr;
use gpui::{ use gpui::{
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div, div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement,
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window, ParentElement, Render, SharedString, Styled, Subscription, Window,
}; };
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::get_client; use smallvec::{smallvec, SmallVec};
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, sync::Arc, time::Duration};
use ui::{ use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants}, button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},
notification::NotificationType,
theme::{scale::ColorScaleStep, ActiveTheme}, theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Root, Size, StyledExt, Disableable, Size, StyledExt,
}; };
use super::app; use crate::device::Device;
const ALPHA_MESSAGE: &str = const LOGO_URL: &str = "brand/coop.svg";
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior."; const TITLE: &str = "Welcome to Coop!";
const JOIN_URL: &str = "https://start.njump.me/"; 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";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx) Onboarding::new(window, cx)
} }
enum PageKind {
Bunker,
Connect,
Selection,
}
pub struct Onboarding { pub struct Onboarding {
app_keys: Keys, bunker_input: Entity<TextInput>,
connect_uri: NostrConnectURI, connect_url: Entity<Option<PathBuf>>,
qr_path: Option<PathBuf>, error_message: Entity<Option<SharedString>>,
nsec_input: Entity<TextInput>, open: PageKind,
use_connect: bool,
use_privkey: bool,
is_loading: bool, is_loading: bool,
#[allow(dead_code)] #[allow(dead_code)]
subscriptions: Vec<Subscription>, subscriptions: SmallVec<[Subscription; 1]>,
} }
impl Onboarding { impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { 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 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(), app_keys.public_key(),
vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()], vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()],
"Coop", "Coop",
); );
let nsec_input = cx.new(|cx| { // Create QR code and save it to a app directory
TextInput::new(window, cx) let qr_path = create_qr(url.to_string().as_str()).ok();
.text_size(Size::XSmall)
.placeholder("nsec...") // 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 // Open Connect page
let qr_path = create_qr(connect_uri.to_string().as_str()).ok(); self.open(PageKind::Connect, window, cx);
cx.new(|cx| { // Wait for connection
// Handle Enter event for nsec input if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) {
let subscriptions = vec![cx.subscribe_in( self.login(signer, window, cx);
&nsec_input, } else {
window, self.set_loading(false, cx);
move |this: &mut Self, _, input_event, window, cx| { self.set_error("Failed to establish connection".to_owned(), cx);
if let InputEvent::PressEnter = input_event { self.open(PageKind::Selection, window, cx);
this.privkey_login(window, cx);
} }
},
)];
Self {
app_keys,
connect_uri,
qr_path,
nsec_input,
use_connect: false,
use_privkey: false,
is_loading: false,
subscriptions,
}
})
}
fn use_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();
self.use_connect = true;
cx.notify();
cx.spawn(|_, mut cx| async move {
let (tx, rx) = oneshot::channel::<NostrProfile>();
cx.background_spawn(async move {
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None)
{
if let Ok(uri) = signer.bunker_uri().await {
let client = get_client();
if let Some(public_key) = uri.remote_signer_public_key() {
let metadata = client
.fetch_metadata(*public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() {
_ = client.set_signer(signer).await;
_ = preload(client, *public_key).await;
}
}
}
}
})
.detach();
if let Ok(profile) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
})
}
})
.detach();
}
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();
} }
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) { fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
@@ -141,208 +163,31 @@ impl Onboarding {
cx.notify(); cx.notify();
} }
fn privkey_login(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn set_error(&mut self, msg: String, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).text().to_string(); self.error_message.update(cx, |this, cx| {
let window_handle = window.window_handle(); *this = Some(msg.into());
cx.notify();
});
if !value.starts_with("nsec") || value.is_empty() { // Dismiss error message after 3 seconds
window.push_notification((NotificationType::Warning, "Private Key is required"), cx); cx.spawn(|this, cx| async move {
return; cx.background_executor().timer(Duration::from_secs(3)).await;
}
let keys = if let Ok(keys) = Keys::parse(&value) { _ = cx.update(|cx| {
keys this.update(cx, |this, cx| {
} else { this.error_message.update(cx, |this, cx| {
window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx); *this = None;
return; cx.notify();
}; })
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(|_, mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<NostrProfile>();
cx.background_spawn(async move {
if let Ok(public_key) = keys.get_public_key().await {
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
if tx.send(NostrProfile::new(public_key, metadata)).is_ok() {
_ = client.set_signer(keys).await;
_ = preload(client, public_key).await;
}
}
}) })
.detach();
if let Ok(profile) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
}); });
}) })
}
})
.detach(); .detach();
} }
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div { fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context<Self>) {
div() self.open = kind;
.w_full() cx.notify();
.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.use_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 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.privkey_login(window, cx);
})),
)
.child(
Button::new("cancel")
.label("Cancel")
.ghost()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.reset(window, cx);
})),
)
} }
} }
@@ -368,7 +213,7 @@ impl Render for Onboarding {
.gap_4() .gap_4()
.child( .child(
svg() svg()
.path("brand/coop.svg") .path(LOGO_URL)
.size_12() .size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
) )
@@ -380,7 +225,7 @@ impl Render for Onboarding {
.text_lg() .text_lg()
.font_semibold() .font_semibold()
.line_height(relative(1.2)) .line_height(relative(1.2))
.child("Welcome to Coop!"), .child(TITLE),
) )
.child( .child(
div() div()
@@ -388,32 +233,167 @@ impl Render for Onboarding {
.text_color( .text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN), cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
) )
.child("A Nostr client for secure communication."), .child(SUBTITLE),
), ),
), ),
) )
.child(div().w_72().map(|_| { .child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| {
if self.use_privkey { match self.open {
self.render_privkey_login(cx) PageKind::Connect => this
} else if self.use_connect { .when_some(self.connect_url.read(cx).as_ref(), |this, path| {
self.render_connect_login(cx) this.child(
} else { div()
self.render_selection(window, cx) .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( .child(
div() div()
.absolute() .my_2()
.bottom_2()
.w_full() .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()
.items_center() .flex_col()
.justify_center() .gap_1()
.text_xs() .text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .child("Bunker URL:")
.text_align(gpui::TextAlign::Center) .child(self.bunker_input.clone())
.child(ALPHA_MESSAGE), .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);
}),
),
}
})),
) )
} }
} }

View File

@@ -1,14 +1,14 @@
use async_utility::task::spawn; use async_utility::task::spawn;
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload}; use common::utils::nip96_upload;
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{ use gpui::{
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter, div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Window, SharedString, Styled, Task, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smol::fs; use smol::fs;
use state::get_client; use std::{str::FromStr, sync::Arc, time::Duration};
use std::str::FromStr;
use ui::{ use ui::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
@@ -17,12 +17,12 @@ use ui::{
ContextModal, Disableable, Sizable, Size, ContextModal, Disableable, Sizable, Size,
}; };
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> { pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
Profile::new(profile, window, cx) Arc::new(Profile::new(window, cx))
} }
pub struct Profile { pub struct Profile {
profile: NostrProfile, profile: Option<Metadata>,
// Form // Form
name_input: Entity<TextInput>, name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>, avatar_input: Entity<TextInput>,
@@ -32,60 +32,96 @@ pub struct Profile {
is_submitting: bool, is_submitting: bool,
// Panel // Panel
name: SharedString, name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
impl Profile { impl Profile {
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let window_handle = window.window_handle();
let name_input = cx.new(|cx| { let name_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall); TextInput::new(window, cx)
if let Some(name) = profile.metadata().display_name.as_ref() {
input.set_text(name, window, cx);
}
input
});
let avatar_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
if let Some(picture) = profile.metadata().picture.as_ref() {
input.set_text(picture, window, cx);
}
input
});
let bio_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx)
.text_size(Size::XSmall) .text_size(Size::XSmall)
.multi_line(); .placeholder("Alice")
if let Some(about) = profile.metadata().about.as_ref() {
input.set_text(about, window, cx);
} else {
input.set_placeholder("A short introduce about you.");
}
input
});
let website_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(website) = profile.metadata().website.as_ref() {
input.set_text(website, window, cx);
} else {
input.set_placeholder("https://your-website.com");
}
input
}); });
cx.new(|cx| Self { let avatar_input = cx.new(|cx| {
profile, TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.jpg")
});
let website_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("https://your-website.com")
});
let bio_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line()
.placeholder("A short introduce about you.")
});
cx.new(|cx| {
let this = Self {
name_input, name_input,
avatar_input, avatar_input,
bio_input, bio_input,
website_input, website_input,
profile: None,
is_loading: false, is_loading: false,
is_submitting: false, is_submitting: false,
name: "Profile".into(), name: "Profile".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
};
let client = get_client();
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?;
Ok(metadata)
});
cx.spawn(|this, mut cx| async move {
if let Ok(Some(metadata)) = task.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this: &mut Profile, cx| {
this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_text(avatar, window, cx);
}
});
this.bio_input.update(cx, |this, cx| {
if let Some(bio) = metadata.about.as_ref() {
this.set_text(bio, window, cx);
}
});
this.name_input.update(cx, |this, cx| {
if let Some(display_name) = metadata.display_name.as_ref() {
this.set_text(display_name, window, cx);
}
});
this.website_input.update(cx, |this, cx| {
if let Some(website) = metadata.website.as_ref() {
this.set_text(website, window, cx);
}
});
this.profile = Some(metadata);
cx.notify();
});
});
}
})
.detach();
this
}) })
} }
@@ -164,12 +200,13 @@ impl Profile {
let bio = self.bio_input.read(cx).text().to_string(); let bio = self.bio_input.read(cx).text().to_string();
let website = self.website_input.read(cx).text().to_string(); let website = self.website_input.read(cx).text().to_string();
let mut new_metadata = self let old_metadata = if let Some(metadata) = self.profile.as_ref() {
.profile metadata.clone()
.metadata() } else {
.to_owned() Metadata::default()
.display_name(name) };
.about(bio);
let mut new_metadata = old_metadata.display_name(name).about(bio);
if let Ok(url) = Url::from_str(&avatar) { if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url); new_metadata = new_metadata.picture(url);
@@ -221,14 +258,6 @@ impl Panel for Profile {
self.name.clone().into_any_element() 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 { fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle) menu.track_focus(&self.focus_handle)
} }
@@ -268,7 +297,7 @@ impl Render for Profile {
if picture.is_empty() { if picture.is_empty() {
this.child( this.child(
img("brand/avatar.png") img("brand/avatar.jpg")
.size_10() .size_10()
.rounded_full() .rounded_full()
.flex_shrink_0(), .flex_shrink_0(),

View File

@@ -1,9 +1,12 @@
use anyhow::{anyhow, Error};
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
use gpui::{ use gpui::{
div, prelude::FluentBuilder, px, uniform_list, AppContext, Context, Entity, FocusHandle, div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, Render, Styled, TextAlign, Window, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::get_client; use smallvec::{smallvec, SmallVec};
use ui::{ use ui::{
button::{Button, ButtonVariants}, button::{Button, ButtonVariants},
input::{InputEvent, TextInput}, input::{InputEvent, TextInput},
@@ -11,108 +14,189 @@ use ui::{
ContextModal, IconName, Sizable, 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 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 { pub struct Relays {
relays: Entity<Vec<Url>>, relays: Entity<Vec<RelayUrl>>,
input: Entity<TextInput>, input: Entity<TextInput>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
is_loading: bool, is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
} }
impl Relays { impl Relays {
pub fn new( pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
relays: Option<Vec<String>>, let client = get_client();
window: &mut Window,
cx: &mut Context<'_, Self>, let relays = cx.new(|cx| {
) -> Self { let relays = vec![
let relays = cx.new(|_| { RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
if let Some(value) = relays { RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
value.into_iter().map(|v| Url::parse(&v).unwrap()).collect() ];
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 { } else {
vec![ Err(anyhow!("Messaging Relays not found."))
Url::parse("wss://auth.nostr1.com").unwrap(),
Url::parse("wss://relay.0xchat.com").unwrap(),
]
} }
}); });
cx.spawn(|this, cx| async move {
if let Ok(relays) = task.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
*this = relays;
cx.notify();
});
});
}
})
.detach();
relays
});
let input = cx.new(|cx| { let input = cx.new(|cx| {
TextInput::new(window, cx) TextInput::new(window, cx)
.text_size(ui::Size::XSmall) .text_size(ui::Size::XSmall)
.small() .small()
.placeholder("wss://...") .placeholder("wss://example.com")
}); });
cx.subscribe_in(&input, window, move |this, _, input_event, 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 { if let InputEvent::PressEnter = input_event {
this.add(window, cx); this.add(window, cx);
} }
}) },
.detach(); ));
Self { Self {
relays, relays,
input, input,
subscriptions,
is_loading: false, is_loading: false,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
} }
})
} }
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let relays = self.relays.read(cx).clone(); let relays = self.relays.read(cx).clone();
let window_handle = window.window_handle(); let window_handle = window.window_handle();
// Show loading spinner
self.set_loading(true, cx); self.set_loading(true, cx);
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = get_client(); let client = get_client();
let (tx, rx) = oneshot::channel(); let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
cx.background_spawn(async move {
let signer = client.signer().await.expect("Signer is required");
let public_key = signer
.get_public_key()
.await
.expect("Cannot get public key");
// If user didn't have any NIP-65 relays, add default ones // If user didn't have any NIP-65 relays, add default ones
// TODO: Is this really necessary? if client.database().relay_list(public_key).await?.is_empty() {
if let Ok(relay_list) = client.database().relay_list(public_key).await {
if relay_list.is_empty() {
let builder = EventBuilder::relay_list(vec![ let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None), (RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None), (RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
(RelayUrl::parse("wss://nos.lol/").unwrap(), None),
]); ]);
if let Err(e) = client.send_event_builder(builder).await { if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {}", e) log::error!("Failed to send relay list event: {}", e);
}
} }
} }
let tags: Vec<Tag> = relays let tags: Vec<Tag> = relays
.into_iter() .iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()])) .map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect(); .collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let output = client.send_event_builder(builder).await?;
if let Ok(output) = client.send_event_builder(builder).await { // Connect to messaging relays
_ = tx.send(output.val); for relay in relays.into_iter() {
}; _ = client.add_relay(&relay).await;
}) _ = client.connect_relay(&relay).await;
.detach(); }
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Close old subscription
client.unsubscribe(&sub_id).await;
// Subscribe to new messages
if let Err(e) = client
.subscribe_with_id(
sub_id,
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0),
None,
)
.await
{
log::error!("Failed to subscribe to new messages: {}", e);
}
Ok(output.val)
});
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
if rx.await.is_ok() { 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.update(cx, |this, cx| {
this.set_loading(false, 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); window.close_modal(cx);
}); })
.ok();
} }
}) })
.detach(); .detach();
@@ -134,7 +218,7 @@ impl Relays {
return; return;
} }
if let Ok(url) = Url::parse(&value) { if let Ok(url) = RelayUrl::parse(&value) {
self.relays.update(cx, |this, cx| { self.relays.update(cx, |this, cx| {
if !this.contains(&url) { if !this.contains(&url) {
this.push(url); this.push(url);
@@ -163,6 +247,7 @@ impl Render for Relays {
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
.w_full()
.child( .child(
div() div()
.px_2() .px_2()
@@ -173,6 +258,7 @@ impl Render for Relays {
.child( .child(
div() div()
.px_2() .px_2()
.w_full()
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
@@ -180,6 +266,7 @@ impl Render for Relays {
div() div()
.flex() .flex()
.items_center() .items_center()
.w_full()
.gap_2() .gap_2()
.child(self.input.clone()) .child(self.input.clone())
.child( .child(
@@ -247,6 +334,7 @@ impl Render for Relays {
items items
}, },
) )
.w_full()
.min_h(px(120.)), .min_h(px(120.)),
) )
} else { } else {
@@ -257,7 +345,7 @@ impl Render for Relays {
.justify_center() .justify_center()
.text_xs() .text_xs()
.text_align(TextAlign::Center) .text_align(TextAlign::Center)
.child("Please add some relays.") .child(HELP_TEXT)
} }
}), }),
) )

View File

@@ -1,18 +1,16 @@
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room}; use chats::{registry::ChatRegistry, room::Room};
use common::{ use common::{profile::NostrProfile, utils::random_name};
profile::NostrProfile, use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client};
utils::{random_name, signer_public_key},
};
use gpui::{ use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App, div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, TextAlign, Window, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign,
Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use smol::Timer; use smol::Timer;
use state::get_client;
use std::{collections::HashSet, time::Duration}; use std::{collections::HashSet, time::Duration};
use ui::{ use ui::{
button::{Button, ButtonRounded}, button::{Button, ButtonRounded},
@@ -21,7 +19,7 @@ use ui::{
ContextModal, Icon, IconName, Sizable, Size, StyledExt, ContextModal, Icon, IconName, Sizable, Size, StyledExt,
}; };
const ALERT: &str = const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
#[derive(Clone, PartialEq, Eq, Deserialize)] #[derive(Clone, PartialEq, Eq, Deserialize)]
@@ -39,7 +37,7 @@ pub struct Compose {
is_submitting: bool, is_submitting: bool,
error_message: Entity<Option<SharedString>>, error_message: Entity<Option<SharedString>>,
#[allow(dead_code)] #[allow(dead_code)]
subscriptions: Vec<Subscription>, subscriptions: SmallVec<[Subscription; 1]>,
} }
impl Compose { impl Compose {
@@ -47,7 +45,6 @@ impl Compose {
let contacts = cx.new(|_| Vec::new()); let contacts = cx.new(|_| Vec::new());
let selected = cx.new(|_| HashSet::new()); let selected = cx.new(|_| HashSet::new());
let error_message = cx.new(|_| None); let error_message = cx.new(|_| None);
let mut subscriptions = Vec::new();
let title_input = cx.new(|cx| { let title_input = cx.new(|cx| {
let name = random_name(2); let name = random_name(2);
@@ -67,17 +64,15 @@ impl Compose {
.placeholder("npub1...") .placeholder("npub1...")
}); });
let mut subscriptions = smallvec![];
// Handle Enter event for user input // Handle Enter event for user input
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&user_input, &user_input,
window, window,
move |this, input, input_event, window, cx| { move |this, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event { if let InputEvent::PressEnter = input_event {
if input.read(cx).text().contains("@") { this.add(window, cx);
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx)
}
} }
}, },
)); ));
@@ -86,7 +81,9 @@ impl Compose {
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>(); let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
cx.background_spawn(async move { cx.background_spawn(async move {
if let Ok(public_key) = signer_public_key(client).await { let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
if let Ok(profiles) = client.database().contacts(public_key).await { if let Ok(profiles) = client.database().contacts(public_key).await {
let members: Vec<NostrProfile> = profiles let members: Vec<NostrProfile> = profiles
.into_iter() .into_iter()
@@ -95,7 +92,6 @@ impl Compose {
_ = tx.send(members); _ = tx.send(members);
} }
}
}) })
.detach(); .detach();
@@ -150,39 +146,39 @@ impl Compose {
} }
let tags = Tags::from_list(tag_list); let tags = Tags::from_list(tag_list);
let client = get_client();
let window_handle = window.window_handle(); let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Event>();
cx.background_spawn(async move { let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
let signer = client.signer().await.expect("Signer is required"); let client = get_client();
let signer = client.signer().await?;
// [IMPORTANT] // [IMPORTANT]
// Make sure this event is never send, // Make sure this event is never send,
// this event existed just use for convert to Coop's Chat Room later. // this event existed just use for convert to Coop's Chat Room later.
if let Ok(event) = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "") let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
.tags(tags) .tags(tags)
.sign(&signer) .sign(&signer)
.await .await?;
{
_ = tx.send(event) Ok(event)
}; });
})
.detach();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
if let Ok(event) = rx.await { if let Ok(event) = event.await {
_ = cx.update_window(window_handle, |_, window, cx| { _ = cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner // Stop loading spinner
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.set_submitting(false, cx); this.set_submitting(false, cx);
}); });
if let Some(chats) = ChatRegistry::global(cx) { let Some(chats) = ChatRegistry::global(cx) else {
let room = Room::parse(&event, cx); return;
};
let room = Room::new(&event, cx);
chats.update(cx, |state, cx| match state.new_room(room, cx) { chats.update(cx, |state, cx| {
match state.push_room(room, cx) {
Ok(_) => { Ok(_) => {
// TODO: open chat panel // TODO: automatically open newly created chat panel
window.close_modal(cx); window.close_modal(cx);
} }
Err(e) => { Err(e) => {
@@ -190,9 +186,9 @@ impl Compose {
this.set_error(Some(e.to_string().into()), cx); this.set_error(Some(e.to_string().into()), cx);
}); });
} }
});
} }
}); });
});
} }
}) })
.detach(); .detach();
@@ -210,102 +206,67 @@ impl Compose {
self.is_submitting self.is_submitting
} }
fn add_npub(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let window_handle = window.window_handle(); let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string(); let content = self.user_input.read(cx).text().to_string();
// Show loading spinner // Show loading spinner
self.set_loading(true, cx); self.set_loading(true, cx);
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 { let Ok(public_key) = PublicKey::parse(&content) else {
self.set_loading(false, cx); self.set_loading(false, cx);
self.set_error(Some("Public Key is not valid".into()), cx); self.set_error(Some("Public Key is not valid".into()), cx);
return; return;
}; };
if self cx.background_spawn(async move {
.contacts let metadata = client
.read(cx) .fetch_metadata(public_key, Duration::from_secs(2))
.iter() .await?
.any(|c| c.public_key() == public_key) .unwrap_or_default();
{
self.set_loading(false, cx); Ok(NostrProfile::new(public_key, metadata))
return; })
}; };
let client = get_client();
let (tx, rx) = oneshot::channel::<Metadata>();
cx.background_spawn(async move {
let metadata = (client
.fetch_metadata(public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(metadata);
})
.detach();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
if let Ok(metadata) = rx.await { 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| { _ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| { let public_key = profile.public_key;
this.insert(0, NostrProfile::new(public_key, metadata));
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
});
});
}
})
.detach();
}
fn add_nip05(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let window_handle = window.window_handle();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
cx.background_spawn(async move {
spawn(async move {
if let Ok(profile) = nip05::profile(&content, None).await {
let metadata = (client
.fetch_metadata(profile.public_key, Duration::from_secs(2))
.await)
.unwrap_or_default();
_ = tx.send(Some(NostrProfile::new(profile.public_key, metadata)));
} else {
_ = tx.send(None);
}
});
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this, cx| {
let public_key = profile.public_key();
this.contacts.update(cx, |this, cx| { this.contacts.update(cx, |this, cx| {
this.insert(0, profile); this.insert(0, profile);
@@ -327,14 +288,16 @@ impl Compose {
}); });
}); });
}); });
} else { }
Err(e) => {
_ = cx.update_window(window_handle, |_, _, cx| { _ = cx.update_window(window_handle, |_, _, cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
this.set_loading(false, cx); this.set_loading(false, cx);
this.set_error(Some("NIP-05 Address is not valid".into()), cx); this.set_error(Some(e.to_string().into()), cx);
}); });
}); });
} }
}
}) })
.detach(); .detach();
} }
@@ -398,7 +361,7 @@ impl Render for Compose {
.px_2() .px_2()
.text_xs() .text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(ALERT), .child(DESCRIPTION),
) )
.when_some(self.error_message.read(cx).as_ref(), |this, msg| { .when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child( this.child(
@@ -442,11 +405,7 @@ impl Render for Compose {
.rounded(ButtonRounded::Size(px(9999.))) .rounded(ButtonRounded::Size(px(9999.)))
.loading(self.is_loading) .loading(self.is_loading)
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
if this.user_input.read(cx).text().contains("@") { this.add(window, cx);
this.add_nip05(window, cx);
} else {
this.add_npub(window, cx);
}
})), })),
) )
.child(self.user_input.clone()), .child(self.user_input.clone()),
@@ -493,7 +452,7 @@ impl Render for Compose {
for ix in range { for ix in range {
let item = contacts.get(ix).unwrap().clone(); 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( items.push(
div() div()
@@ -512,10 +471,10 @@ impl Render for Compose {
.text_xs() .text_xs()
.child( .child(
div().flex_shrink_0().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| { .when(is_select, |this| {
this.child( this.child(
@@ -536,7 +495,7 @@ impl Render for Compose {
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
window.dispatch_action( window.dispatch_action(
Box::new(SelectContact( Box::new(SelectContact(
item.public_key(), item.public_key,
)), )),
cx, cx,
); );

View File

@@ -1,182 +0,0 @@
use crate::views::app::{AddPanel, PanelKind};
use chats::registry::ChatRegistry;
use gpui::{
div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
TextAlign, Window,
};
use ui::{
dock_area::dock::DockPlacement,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, Collapsible, Icon, IconName, StyledExt,
};
pub struct Inbox {
label: SharedString,
is_collapsed: bool,
}
impl Inbox {
pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self {
Self {
label: "Inbox".into(),
is_collapsed: false,
}
}
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
.h_8()
.px_1()
.flex()
.items_center()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(Skeleton::new().w_20().h_3().rounded_sm())
})
}
fn render_item(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(chats) = ChatRegistry::global(cx) {
div().map(|this| {
let state = chats.read(cx);
let rooms = state.rooms();
if state.is_loading() {
this.children(self.render_skeleton(5))
} else if rooms.is_empty() {
this.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_align(TextAlign::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.children(rooms.iter().map(|model| {
let room = model.read(cx);
let room_id: SharedString = room.id.to_string().into();
div()
.id(room_id)
.h_8()
.px_1()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.child(div().flex_1().truncate().font_medium().map(|this| {
if room.is_group {
this.flex()
.items_center()
.gap_2()
.child(img("brand/avatar.png").size_6().rounded_full())
.child(room.name())
} else {
this.when_some(room.members.first(), |this, sender| {
this.flex()
.items_center()
.gap_2()
.child(
img(sender.avatar())
.size_6()
.rounded_full()
.flex_shrink_0(),
)
.child(sender.name())
})
}
}))
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(room.last_seen.ago()),
)
.on_click({
let id = room.id;
cx.listener(move |this, _, window, cx| {
this.action(id, window, cx);
})
})
}))
}
})
} else {
div().children(self.render_skeleton(5))
}
}
fn action(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
cx,
);
}
}
impl Collapsible for Inbox {
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
}
impl Render for Inbox {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.px_2()
.gap_1()
.child(
div()
.id("inbox")
.h_7()
.px_1()
.flex()
.items_center()
.rounded(px(cx.theme().radius))
.text_xs()
.font_semibold()
.child(
Icon::new(IconName::ChevronDown)
.size_6()
.when(self.is_collapsed, |this| {
this.rotate(percentage(270. / 360.))
}),
)
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(move |view, _event, _window, cx| {
view.is_collapsed = !view.is_collapsed;
cx.notify();
})),
)
.when(!self.is_collapsed, |this| {
this.child(self.render_item(window, cx))
})
}
}

View File

@@ -1,33 +1,33 @@
use crate::views::sidebar::inbox::Inbox; use chats::{registry::ChatRegistry, room::Room};
use compose::Compose; use compose::Compose;
use gpui::{ use gpui::{
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, AppContext, Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Stateful,
StatefulInteractiveElement, Styled, Window, StatefulInteractiveElement, Styled, Window,
}; };
use ui::{ use ui::{
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent}, dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu, popup_menu::PopupMenu,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme}, theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
}; };
use super::app::AddPanel;
mod compose; mod compose;
mod inbox;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx) Sidebar::new(window, cx)
} }
pub struct Sidebar { pub struct Sidebar {
// Panel
name: SharedString, name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle, focus_handle: FocusHandle,
// Dock label: SharedString,
inbox: Entity<Inbox>, is_collapsed: bool,
} }
impl Sidebar { impl Sidebar {
@@ -35,19 +35,19 @@ impl Sidebar {
cx.new(|cx| Self::view(window, cx)) cx.new(|cx| Self::view(window, cx))
} }
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self { fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let inbox = cx.new(|cx| Inbox::new(window, cx)); let focus_handle = cx.focus_handle();
let label = SharedString::from("Inbox");
Self { Self {
name: "Sidebar".into(), name: "Sidebar".into(),
closable: true, is_collapsed: false,
zoomable: true, focus_handle,
focus_handle: cx.focus_handle(), label,
inbox,
} }
} }
fn show_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let compose = cx.new(|cx| Compose::new(window, cx)); let compose = cx.new(|cx| Compose::new(window, cx));
window.open_modal(cx, move |modal, window, cx| { window.open_modal(cx, move |modal, window, cx| {
@@ -79,6 +79,92 @@ impl Sidebar {
) )
}) })
} }
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
let room = room.read(cx);
div()
.id(ix)
.px_1()
.h_8()
.w_full()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.child(div().flex_1().truncate().font_medium().map(|this| {
if room.is_group() {
this.flex()
.items_center()
.gap_2()
.child(
div()
.flex()
.justify_center()
.items_center()
.size_6()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
.child(Icon::new(IconName::GroupFill).size_3().text_color(
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
)),
)
.when_some(room.name(), |this, name| this.child(name))
} else {
this.when_some(room.first_member(), |this, member| {
this.flex()
.items_center()
.gap_2()
.child(
img(member.avatar.clone())
.size_6()
.rounded_full()
.flex_shrink_0(),
)
.child(member.name.clone())
})
}
}))
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(room.ago()),
)
.on_click({
let id = room.id;
cx.listener(move |this, _, window, cx| {
this.open(id, window, cx);
})
})
}
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
.h_8()
.w_full()
.px_1()
.flex()
.items_center()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(Skeleton::new().w_20().h_3().rounded_sm())
})
}
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
super::app::PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
);
}
} }
impl Panel for Sidebar { impl Panel for Sidebar {
@@ -90,14 +176,6 @@ impl Panel for Sidebar {
self.name.clone().into_any_element() 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 { fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle) menu.track_focus(&self.focus_handle)
} }
@@ -117,14 +195,24 @@ impl Focusable for Sidebar {
impl Render for Sidebar { impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex() let entity = cx.entity();
.w_full()
.py_3()
.gap_3()
.child(
v_flex().px_2().gap_1().child(
div() div()
.id("new") .flex()
.flex_col()
.size_full()
.child(
div()
.px_2()
.py_3()
.w_full()
.flex_shrink_0()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id("new_message")
.flex() .flex()
.items_center() .items_center()
.gap_2() .gap_2()
@@ -149,9 +237,111 @@ impl Render for Sidebar {
) )
.child("New Message") .child("New Message")
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))) .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))), .on_click(cx.listener(|this, _, window, cx| {
// Open compose modal
this.render_compose(window, cx);
})),
)
.child(Empty),
)
.child(
div()
.px_2()
.w_full()
.flex_1()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id("inbox_header")
.px_1()
.h_7()
.flex()
.items_center()
.flex_shrink_0()
.rounded(px(cx.theme().radius))
.text_xs()
.font_semibold()
.child(
Icon::new(IconName::ChevronDown)
.size_6()
.when(self.is_collapsed, |this| {
this.rotate(percentage(270. / 360.))
}),
)
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(move |view, _event, _window, cx| {
view.is_collapsed = !view.is_collapsed;
cx.notify();
})),
)
.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();
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."),
), ),
) )
.child(self.inbox.clone()) } 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

@@ -6,12 +6,14 @@ publish = false
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
state = { path = "../state" } global = { path = "../global" }
gpui.workspace = true gpui.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true chrono.workspace = true
smallvec.workspace = true
smol.workspace = true smol.workspace = true
oneshot.workspace = true oneshot.workspace = true
log.workspace = true

View File

@@ -1,12 +1,13 @@
use anyhow::anyhow;
use common::utils::{compare, room_hash, signer_public_key};
use gpui::{App, AppContext, Context, Entity, Global};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use state::get_client;
use std::cmp::Reverse; use std::cmp::Reverse;
use crate::room::Room; 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) { pub fn init(cx: &mut App) {
ChatRegistry::register(cx); ChatRegistry::register(cx);
@@ -34,41 +35,6 @@ impl ChatRegistry {
// Set global state // Set global state
cx.set_global(GlobalChatRegistry(entity.clone())); cx.set_global(GlobalChatRegistry(entity.clone()));
// Observe and load metadata for any new rooms
cx.observe_new::<Room>(|this, _window, cx| {
let client = get_client();
let pubkeys = this.pubkeys();
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, Metadata)>>();
cx.background_spawn(async move {
let mut profiles = Vec::new();
for public_key in pubkeys.into_iter() {
if let Ok(metadata) = client.database().metadata(public_key).await {
profiles.push((public_key, metadata.unwrap_or_default()));
}
}
_ = tx.send(profiles);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(profiles) = rx.await {
if let Some(room) = this.upgrade() {
_ = cx.update_entity(&room, |this, cx| {
for profile in profiles.into_iter() {
this.set_metadata(profile.0, profile.1);
}
cx.notify();
});
}
}
})
.detach();
})
.detach();
entity entity
}) })
} }
@@ -86,16 +52,23 @@ impl ChatRegistry {
pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) { pub fn load_chat_rooms(&mut self, cx: &mut Context<Self>) {
let client = get_client(); let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<Event>>();
cx.background_spawn(async move { let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
if let Ok(public_key) = signer_public_key(client).await { let signer = client.signer().await?;
let filter = Filter::new() let public_key = signer.get_public_key().await?;
let send = Filter::new()
.kind(Kind::PrivateDirectMessage) .kind(Kind::PrivateDirectMessage)
.author(public_key); .author(public_key);
// Get all DM events from database let recv = Filter::new()
if let Ok(events) = client.database().query(filter).await { .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 let result: Vec<Event> = events
.into_iter() .into_iter()
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) .filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
@@ -103,33 +76,37 @@ impl ChatRegistry {
.sorted_by_key(|ev| Reverse(ev.created_at)) .sorted_by_key(|ev| Reverse(ev.created_at))
.collect(); .collect();
_ = tx.send(result); Ok(result)
} });
}
})
.detach();
cx.spawn(|this, cx| async move { cx.spawn(|this, cx| async move {
if let Ok(events) = rx.await { if let Ok(events) = task.await {
_ = cx.update(|cx| { _ = cx.update(|cx| {
_ = this.update(cx, |this, cx| { _ = this.update(cx, |this, cx| {
let current_rooms = this.current_rooms_ids(cx); if !events.is_empty() {
let current_ids = this.current_rooms_ids(cx);
let items: Vec<Entity<Room>> = events let items: Vec<Entity<Room>> = events
.into_iter() .into_iter()
.filter_map(|ev| { .filter_map(|ev| {
let new = room_hash(&ev); let new = room_hash(&ev);
// Filter all seen events // Filter all seen rooms
if !current_rooms.iter().any(|this| this == &new) { if !current_ids.iter().any(|this| this == &new) {
Some(cx.new(|cx| Room::parse(&ev, cx))) Some(Room::new(&ev, cx))
} else { } else {
None None
} }
}) })
.collect(); .collect();
this.rooms.extend(items);
this.is_loading = false; 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(); cx.notify();
}); });
}); });
@@ -138,7 +115,7 @@ impl ChatRegistry {
.detach(); .detach();
} }
pub fn rooms(&self) -> &Vec<Entity<Room>> { pub fn rooms(&self) -> &[Entity<Room>] {
&self.rooms &self.rooms
} }
@@ -146,56 +123,52 @@ impl ChatRegistry {
self.is_loading self.is_loading
} }
pub fn get(&self, id: &u64, cx: &App) -> Option<Entity<Room>> { pub fn get(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms self.rooms
.iter() .iter()
.find(|model| &model.read(cx).id == id) .find(|model| model.read(cx).id == *id)
.cloned() .map(|room| room.downgrade())
} }
pub fn new_room(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> { pub fn push_room(
&mut self,
room: Entity<Room>,
cx: &mut Context<Self>,
) -> Result<(), anyhow::Error> {
if !self if !self
.rooms .rooms
.iter() .iter()
.any(|current| compare(&current.read(cx).pubkeys(), &room.pubkeys())) .any(|current| current.read(cx) == room.read(cx))
{ {
self.rooms.insert(0, cx.new(|_| room)); self.rooms.insert(0, room);
cx.notify(); cx.notify();
Ok(()) Ok(())
} else { } else {
Err(anyhow!("Room is existed")) Err(anyhow!("Room already exists"))
} }
} }
pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) { pub fn push_message(&mut self, event: Event, cx: &mut Context<Self>) {
// Get all pubkeys from event's tags for comparision let id = room_hash(&event);
let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect();
pubkeys.push(event.pubkey);
if let Some(room) = self if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
.rooms
.iter()
.find(|room| compare(&room.read(cx).pubkeys(), &pubkeys))
{
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
this.last_seen.set(event.created_at); this.last_seen = LastSeen(event.created_at);
this.new_messages.update(cx, |this, cx| { cx.emit(IncomingEvent { event });
this.push(event);
cx.notify();
});
cx.notify(); cx.notify();
}); });
// Re sort rooms by last seen // Re-sort rooms by last seen
self.rooms self.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen())); .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(); cx.notify();
} else {
let room = cx.new(|cx| Room::parse(&event, cx));
self.rooms.insert(0, room);
cx.notify();
}
} }
} }

View File

@@ -1,138 +1,282 @@
use std::collections::HashSet;
use anyhow::{anyhow, Error};
use common::{ use common::{
last_seen::LastSeen, last_seen::LastSeen,
profile::NostrProfile, profile::NostrProfile,
utils::{compare, random_name, room_hash}, utils::{device_pubkey, room_hash},
}; };
use gpui::{App, AppContext, Entity, SharedString}; use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys};
use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use std::collections::HashSet; use smallvec::{smallvec, SmallVec};
#[derive(Debug, Clone)]
pub struct IncomingEvent {
pub event: Event,
}
pub struct Room { pub struct Room {
pub id: u64, pub id: u64,
pub title: Option<SharedString>,
pub owner: NostrProfile, // Owner always match current user
pub members: Vec<NostrProfile>, // Extract from event's tags
pub last_seen: LastSeen, pub last_seen: LastSeen,
pub is_group: bool, /// Subject of the room
pub new_messages: Entity<Vec<Event>>, // Hold all new messages pub name: Option<SharedString>,
/// All members of the room
pub members: SmallVec<[NostrProfile; 2]>,
} }
impl EventEmitter<IncomingEvent> for Room {}
impl PartialEq for Room { impl PartialEq for Room {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
compare(&self.pubkeys(), &other.pubkeys()) self.id == other.id
} }
} }
impl Room { impl Room {
pub fn new( pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
id: u64,
owner: NostrProfile,
members: Vec<NostrProfile>,
title: Option<SharedString>,
last_seen: LastSeen,
cx: &mut App,
) -> Self {
let new_messages = cx.new(|_| Vec::new());
let is_group = members.len() > 1;
let title = if title.is_none() {
Some(random_name(2).into())
} else {
title
};
Self {
id,
owner,
members,
title,
last_seen,
is_group,
new_messages,
}
}
/// Convert nostr event to room
pub fn parse(event: &Event, cx: &mut App) -> Room {
let id = room_hash(event); let id = room_hash(event);
let last_seen = LastSeen(event.created_at); let last_seen = LastSeen(event.created_at);
// Always equal to current user // Get the subject from the event's tags
let owner = NostrProfile::new(event.pubkey, Metadata::default()); let name = if let Some(tag) = event.tags.find(TagKind::Subject) {
// Get all pubkeys that invole in this group
let members: Vec<NostrProfile> = event
.tags
.public_keys()
.collect::<HashSet<_>>()
.into_iter()
.map(|public_key| NostrProfile::new(*public_key, Metadata::default()))
.collect();
// Get title from event's tags
let title = if let Some(tag) = event.tags.find(TagKind::Subject) {
tag.content().map(|s| s.to_owned().into()) tag.content().map(|s| s.to_owned().into())
} else { } else {
None None
}; };
Self::new(id, owner, members, title, last_seen, cx) // Create a task for loading metadata
let load_metadata = Self::load_metadata(event, cx);
let room = cx.new(|cx| {
let this = Self {
id,
last_seen,
name,
members: smallvec![],
};
cx.spawn(|this, cx| async move {
if let Ok(profiles) = load_metadata.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this: &mut Room, cx| {
// Update the room's name if it's not already set
if this.name.is_none() {
let mut name = profiles
.iter()
.take(2)
.map(|profile| profile.name.to_string())
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
} }
/// Set contact's metadata by public key this.name = Some(name.into())
pub fn set_metadata(&mut self, public_key: PublicKey, metadata: Metadata) { };
if self.owner.public_key() == public_key { // Update the room's members
self.owner.set_metadata(&metadata); this.members.extend(profiles);
cx.notify();
});
});
}
})
.detach();
this
});
room
} }
for member in self.members.iter_mut() { pub fn id(&self) -> u64 {
if member.public_key() == public_key { self.id
member.set_metadata(&metadata);
}
}
} }
/// Get room's member by public key /// Get room's member by public key
pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> { pub fn member(&self, public_key: &PublicKey) -> Option<NostrProfile> {
if &self.owner.public_key() == public_key {
Some(self.owner.clone())
} else {
self.members self.members
.iter() .iter()
.find(|m| &m.public_key() == public_key) .find(|m| &m.public_key == public_key)
.cloned() .cloned()
} }
/// Get room's first member's public key
pub fn first_member(&self) -> Option<&NostrProfile> {
self.members.first()
}
/// Collect room's member's public keys
pub fn public_keys(&self) -> Vec<PublicKey> {
self.members.iter().map(|m| m.public_key).collect()
} }
/// Get room's display name /// Get room's display name
pub fn name(&self) -> String { pub fn name(&self) -> Option<SharedString> {
if self.members.len() <= 2 { self.name.clone()
self.members }
/// Determine if room is a group
pub fn is_group(&self) -> bool {
self.members.len() > 2
}
/// Get room's last seen
pub fn last_seen(&self) -> LastSeen {
self.last_seen
}
/// Get room's last seen as ago format
pub fn ago(&self) -> SharedString {
self.last_seen.ago()
}
/// Sync inbox relays for all room's members
pub fn verify_inbox_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
let client = get_client();
let pubkeys = self.public_keys();
cx.background_spawn(async move {
let mut result = Vec::with_capacity(pubkeys.len());
for pubkey in pubkeys.into_iter() {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(pubkey)
.limit(1);
let is_ready = client
.database()
.query(filter)
.await
.ok()
.and_then(|events| events.first_owned())
.is_some();
result.push((pubkey, is_ready));
}
Ok(result)
})
}
/// Send message to all room's members
///
/// NIP-4e: Message will be signed by the device signer
pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
let client = get_client();
let pubkeys = self.public_keys();
cx.background_spawn(async move {
let 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 tags: Vec<Tag> = pubkeys
.iter() .iter()
.map(|profile| profile.name()) .filter_map(|pubkey| {
.collect::<Vec<_>>() if pubkey != &user_pubkey {
.join(", ") Some(Tag::public_key(*pubkey))
} else { } else {
let name = self None
.members }
.iter() })
.take(2) .collect();
.map(|profile| profile.name())
.collect::<Vec<_>>()
.join(", ");
format!("{}, +{}", name, self.members.len() - 2) 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 {
report.push(e.to_string());
} }
} }
pub fn last_seen(&self) -> &LastSeen { Ok(report)
&self.last_seen })
} }
/// Get all public keys from current room /// Load metadata for all members
pub fn pubkeys(&self) -> Vec<PublicKey> { pub fn load_messages(&self, cx: &App) -> Task<Result<Events, Error>> {
let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect(); let client = get_client();
pubkeys.push(self.owner.public_key()); let pubkeys = self.public_keys();
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(pubkeys.iter().copied())
.pubkeys(pubkeys);
pubkeys cx.background_spawn(async move {
let query = client.database().query(filter).await?;
Ok(query)
})
}
/// Load metadata for all members
fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> {
let client = get_client();
let mut pubkeys = vec![];
// Get all pubkeys from event's tags
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
pubkeys.push(event.pubkey);
cx.background_spawn(async move {
let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().await?;
let mut profiles = vec![];
for public_key in pubkeys.into_iter() {
if let Ok(result) = client.database().metadata(public_key).await {
let metadata = result.unwrap_or_default();
let profile = NostrProfile::new(public_key, metadata);
if public_key == signer_pubkey {
profiles.push(profile);
} else {
profiles.insert(0, profile);
}
}
}
Ok(profiles)
})
} }
} }

View File

@@ -5,12 +5,15 @@ edition = "2021"
publish = false publish = false
[dependencies] [dependencies]
global = { path = "../global" }
gpui.workspace = true gpui.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true chrono.workspace = true
dirs.workspace = true dirs.workspace = true
smallvec.workspace = true
random_name_generator = "0.3.6" random_name_generator = "0.3.6"
qrcode-generator = "5.0.0" qrcode-generator = "5.0.0"

View File

@@ -1,55 +1,54 @@
use chrono::{Datelike, Local, TimeZone}; use chrono::{Local, TimeZone};
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LastSeen(pub Timestamp); pub struct LastSeen(pub Timestamp);
impl LastSeen { impl LastSeen {
pub fn ago(&self) -> SharedString { pub fn ago(&self) -> SharedString {
let now = Local::now(); let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
let diff = (now - input_time).num_hours(); chrono::LocalResult::Single(time) => time,
_ => return "Invalid timestamp".into(),
if diff < 24 { };
let duration = now.signed_duration_since(input_time); let duration = now.signed_duration_since(input_time);
if duration.num_seconds() < 60 { match duration {
"now".to_string().into() d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
} else if duration.num_minutes() == 1 { d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
"1m".to_string().into() d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
} else if duration.num_minutes() < 60 { d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
format!("{}m", duration.num_minutes()).into() _ => input_time.format("%b %d").to_string(),
} else if duration.num_hours() == 1 {
"1h".to_string().into()
} else if duration.num_hours() < 24 {
format!("{}h", duration.num_hours()).into()
} else if duration.num_days() == 1 {
"1d".to_string().into()
} else {
format!("{}d", duration.num_days()).into()
}
} else {
input_time.format("%b %d").to_string().into()
} }
.into()
} }
pub fn human_readable(&self) -> SharedString { pub fn human_readable(&self) -> SharedString {
let now = Local::now(); let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "Invalid timestamp".into(),
};
if input_time.day() == now.day() { let input_date = input_time.date_naive();
format!("Today at {}", input_time.format("%H:%M %p")).into() let now_date = now.date_naive();
} else if input_time.day() == now.day() - 1 { let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
format!("Yesterday at {}", input_time.format("%H:%M %p")).into()
} else { let time_format = input_time.format("%H:%M %p");
format!(
"{}, {}", match input_date {
input_time.format("%d/%m/%y"), date if date == now_date => format!("Today at {time_format}"),
input_time.format("%H:%M %p") date if date == yesterday_date => format!("Yesterday at {time_format}"),
) _ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
.into()
} }
.into()
} }
pub fn set(&mut self, created_at: Timestamp) { pub fn set(&mut self, created_at: Timestamp) {

View File

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

View File

@@ -1,86 +1,65 @@
use crate::constants::IMAGE_SERVICE; use global::constants::IMAGE_SERVICE;
use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::SmallVec;
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrProfile { pub struct NostrProfile {
public_key: PublicKey, pub public_key: PublicKey,
metadata: Metadata, pub avatar: SharedString,
} pub name: SharedString,
pub messaging_relays: Option<SmallVec<[RelayUrl; 3]>>,
impl AsRef<PublicKey> for NostrProfile {
fn as_ref(&self) -> &PublicKey {
&self.public_key
}
}
impl AsRef<Metadata> for NostrProfile {
fn as_ref(&self) -> &Metadata {
&self.metadata
}
}
impl Eq for NostrProfile {}
impl PartialEq for NostrProfile {
fn eq(&self, other: &Self) -> bool {
self.public_key() == other.public_key()
}
} }
impl NostrProfile { impl NostrProfile {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
let name = Self::extract_name(&public_key, &metadata);
let avatar = Self::extract_avatar(&metadata);
Self { Self {
public_key, public_key,
metadata, name,
avatar,
messaging_relays: None,
} }
} }
/// Get contact's public key /// Set contact's relays
pub fn public_key(&self) -> PublicKey { pub fn relays(mut self, relays: Option<SmallVec<[RelayUrl; 3]>>) -> Self {
self.public_key self.messaging_relays = relays;
self
} }
/// Get contact's avatar fn extract_avatar(metadata: &Metadata) -> SharedString {
pub fn avatar(&self) -> String { metadata
if let Some(picture) = &self.metadata.picture { .picture
if picture.len() > 1 { .as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
format!( format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE, picture IMAGE_SERVICE, picture
) )
} else { .into()
"brand/avatar.png".into() })
} .unwrap_or_else(|| "brand/avatar.jpg".into())
} else {
"brand/avatar.png".into()
}
} }
/// Get contact's name, fallback to public key as shorted format fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {
pub fn name(&self) -> String { if let Some(display_name) = metadata.display_name.as_ref() {
if let Some(display_name) = &self.metadata.display_name {
if !display_name.is_empty() { if !display_name.is_empty() {
return display_name.to_owned(); return display_name.into();
} }
} }
if let Some(name) = &self.metadata.name { if let Some(name) = metadata.name.as_ref() {
if !name.is_empty() { if !name.is_empty() {
return name.to_owned(); return name.into();
} }
} }
let pubkey = self.public_key.to_string(); let pubkey = public_key.to_hex();
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..])
}
/// Get contact's metadata format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
pub fn metadata(&mut self) -> &Metadata {
&self.metadata
}
/// Set contact's metadata
pub fn set_metadata(&mut self, metadata: &Metadata) {
self.metadata = metadata.clone()
} }
} }

View File

@@ -1,62 +1,13 @@
use crate::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP96_SERVER}; use anyhow::Context;
use global::constants::NIP96_SERVER;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use rnglib::{Language, RNG}; use rnglib::{Language, RNG};
use std::{ use std::{
collections::HashSet, collections::HashSet,
hash::{DefaultHasher, Hash, Hasher}, hash::{DefaultHasher, Hash, Hasher},
time::Duration,
}; };
pub async fn signer_public_key(client: &Client) -> anyhow::Result<PublicKey, anyhow::Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key)
}
pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> {
let sync_opts = SyncOptions::default();
let subscription = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Get contact list
_ = client.sync(subscription, &sync_opts).await;
let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Create a filter for getting all gift wrapped events send to current user
let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
// Create a filter for getting new message
let new_message = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0);
// Subscribe for all messages
_ = client
.subscribe_with_id(
all_messages_sub_id,
all_messages,
Some(
SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(3))),
),
)
.await;
// Subscribe for new message
_ = client
.subscribe_with_id(new_message_sub_id, new_message, None)
.await;
Ok(())
}
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> { pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
let signer = client.signer().await?; let signer = client.signer().await?;
let server_url = Url::parse(NIP96_SERVER)?; let server_url = Url::parse(NIP96_SERVER)?;
@@ -68,13 +19,39 @@ pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url,
} }
pub fn room_hash(event: &Event) -> u64 { pub fn room_hash(event: &Event) -> u64 {
let pubkeys: Vec<&PublicKey> = event.tags.public_keys().unique().collect();
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<&PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(&event.pubkey);
pubkeys.extend(
event
.tags
.public_keys()
.unique()
.sorted()
.collect::<Vec<_>>(),
);
// Generate unique hash // Generate unique hash
pubkeys.hash(&mut hasher); pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish() hasher.finish()
} }
pub fn device_pubkey(event: &Event) -> Result<PublicKey, anyhow::Error> {
let n_tag = event.tags.find(TagKind::custom("n")).context("Invalid")?;
let hex = n_tag.content().context("Invalid")?;
let pubkey = PublicKey::parse(hex)?;
Ok(pubkey)
}
pub fn random_name(length: usize) -> String { pub fn random_name(length: usize) -> String {
let rng = RNG::from(&Language::Roman); let rng = RNG::from(&Language::Roman);
rng.generate_names(length, true).join("-").to_lowercase() rng.generate_names(length, true).join("-").to_lowercase()

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "state" name = "global"
version = "0.0.0" version = "0.0.0"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -7,3 +7,6 @@ publish = false
[dependencies] [dependencies]
nostr-sdk.workspace = true nostr-sdk.workspace = true
dirs.workspace = true dirs.workspace = true
smol.workspace = true
whoami = "1.5.2"

View File

@@ -1,10 +1,24 @@
pub const KEYRING_SERVICE: &str = "Coop Safe Storage";
pub const APP_NAME: &str = "Coop"; pub const APP_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop"; pub const APP_ID: &str = "su.reya.coop";
pub const CLIENT_KEYRING: &str = "Coop Client Keys";
pub const MASTER_KEYRING: &str = "Coop Master Keys";
pub const DEVICE_ANNOUNCEMENT_KIND: u16 = 10044;
pub const DEVICE_REQUEST_KIND: u16 = 4454;
pub const DEVICE_RESPONSE_KIND: u16 = 4455;
/// Bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://purplepag.es",
];
/// Subscriptions /// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";
pub const DEVICE_SUB_ID: &str = "listen_device_announcement";
/// Image Resizer Service /// Image Resizer Service
pub const IMAGE_SERVICE: &str = "https://wsrv.nl"; pub const IMAGE_SERVICE: &str = "https://wsrv.nl";

104
crates/global/src/lib.rs Normal file
View File

@@ -0,0 +1,104 @@
use constants::{ALL_MESSAGES_SUB_ID, APP_ID};
use dirs::config_dir;
use nostr_sdk::prelude::*;
use smol::lock::Mutex;
use std::{
fs,
sync::{Arc, OnceLock},
time::Duration,
};
pub mod constants;
/// Nostr Client
static CLIENT: OnceLock<Client> = OnceLock::new();
/// Current App Name
static APP_NAME: OnceLock<Arc<str>> = OnceLock::new();
/// NIP-4e: Device Keys, used for encryption
static DEVICE_KEYS: Mutex<Option<Arc<dyn NostrSigner>>> = Mutex::new(None);
/// NIP-4e: Device Name, used for display purposes
static DEVICE_NAME: Mutex<Option<Arc<String>>> = Mutex::new(None);
/// Nostr Client instance
pub fn get_client() -> &'static Client {
CLIENT.get_or_init(|| {
// Setup app data folder
let config_dir = config_dir().expect("Config directory not found");
let app_dir = config_dir.join(APP_ID);
// Create app directory if it doesn't exist
_ = fs::create_dir_all(&app_dir);
// Setup database
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
// NIP-65
.gossip(true)
// Skip all very slow relays
.max_avg_latency(Duration::from_secs(2));
// Setup Nostr Client
ClientBuilder::default().database(lmdb).opts(opts).build()
})
}
/// Get app name
pub fn get_app_name() -> &'static str {
APP_NAME.get_or_init(|| {
Arc::from(format!(
"Coop on {} ({})",
whoami::distro(),
whoami::devicename()
))
})
}
/// Get device keys
pub async fn get_device_keys() -> Option<Arc<dyn NostrSigner>> {
let guard = DEVICE_KEYS.lock().await;
guard.clone()
}
/// Set device keys
pub async fn set_device_keys<T>(signer: 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()))
}

View File

@@ -1,34 +0,0 @@
use dirs::config_dir;
use nostr_sdk::prelude::*;
use std::{fs, sync::OnceLock, time::Duration};
static CLIENT: OnceLock<Client> = OnceLock::new();
pub fn initialize_client() -> &'static Client {
// Setup app data folder
let config_dir = config_dir().expect("Config directory not found");
let app_dir = config_dir.join("Coop/");
// Create app directory if it doesn't exist
_ = fs::create_dir_all(&app_dir);
// Setup database
let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized");
// Client options
let opts = Options::new()
// NIP-65
.gossip(true)
// Skip all very slow relays
.max_avg_latency(Duration::from_millis(800));
// Setup Nostr Client
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
CLIENT.set(client).expect("Client is already initialized!");
CLIENT.get().expect("Client is NOT initialized!")
}
pub fn get_client() -> &'static Client {
CLIENT.get().expect("Client is NOT initialized!")
}

View File

@@ -634,7 +634,18 @@ impl ButtonVariant {
_ => cx.theme().base.step(cx, ColorScaleStep::THREE), _ => cx.theme().base.step(cx, ColorScaleStep::THREE),
}; };
let fg = cx.theme().base.step(cx, ColorScaleStep::ELEVEN); let fg = match self {
ButtonVariant::Primary => match cx.theme().accent.name().to_string().as_str() {
"Sky" => cx.theme().base.darken(cx),
"Mint" => cx.theme().base.darken(cx),
"Lime" => cx.theme().base.darken(cx),
"Amber" => cx.theme().base.darken(cx),
"Yellow" => cx.theme().base.darken(cx),
_ => cx.theme().accent.step(cx, ColorScaleStep::ONE),
},
_ => cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
};
let border = bg; let border = bg;
let underline = self.underline(window, cx); let underline = self.underline(window, cx);
let shadow = false; let shadow = false;

View File

@@ -358,8 +358,6 @@ impl Render for Dock {
return div(); return div();
} }
let cache_style = gpui::StyleRefinement::default().v_flex().size_full();
div() div()
.relative() .relative()
.overflow_hidden() .overflow_hidden()
@@ -375,7 +373,7 @@ impl Render for Dock {
.map(|this| match &self.panel { .map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()), DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()), DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), DockItem::Panel { view, .. } => this.child(view.clone().view()),
}) })
.child(self.render_resize_handle(window, cx)) .child(self.render_resize_handle(window, cx))
.child(DockElement { .child(DockElement {

View File

@@ -47,8 +47,7 @@ impl Modal {
.border_1() .border_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.rounded_lg() .rounded_lg()
.shadow_xl() .shadow_xl();
.min_h_48();
Self { Self {
base, base,

View File

@@ -212,6 +212,11 @@ impl Root {
pub fn view(&self) -> &AnyView { pub fn view(&self) -> &AnyView {
&self.view &self.view
} }
/// Replace the root view of the Root.
pub fn replace_view(&mut self, view: AnyView) {
self.view = view;
}
} }
impl Render for Root { impl Render for Root {

View File

@@ -11,7 +11,7 @@ use gpui::{
use std::rc::Rc; use std::rc::Rc;
const HEIGHT: Pixels = px(34.); const HEIGHT: Pixels = px(34.);
const TITLE_BAR_HEIGHT: Pixels = px(35.); const TITLE_BAR_HEIGHT: Pixels = px(34.);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
const TITLE_BAR_LEFT_PADDING: Pixels = px(80.); const TITLE_BAR_LEFT_PADDING: Pixels = px(80.);
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]